An interactive demonstration of a robust, keyboard-navigable scheduling calendar. Explore 5 distinct views (Day, Week, Month, Agenda, Timeline) with drag-and-drop rescheduling, recurrence expansion, resource grouping (active resource selector), and automated conflict detection.
June 2026
Implementation Source Code
Calendar.tsx (Scheduling Calendar Source)
"use client";
import { useState, useCallback, useMemo, useEffect, useRef, CSSProperties } from "react";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
Modal,
Button,
Text,
Group,
Stack,
TextInput,
ColorSwatch,
Select,
MultiSelect,
Textarea,
Box,
NumberInput,
Chip,
} from "@mantine/core";
import { IoHelpCircleOutline } from "react-icons/io5";
import { DatePickerInput, TimePicker } from "@mantine/dates";
import dayjs from "dayjs";
import {
CalendarProps,
CalendarView,
CalendarEvent,
CalendarEventInternal,
CalendarEventType,
EventType,
DurationType,
processEvents,
checkEventConflicts,
expandRecurringEvents,
DEFAULT_EVENT_COLORS,
} from "./types";
import {
resolveEventColor,
getEnabledEventTypes,
getEnabledDurationTypes,
inferDurationType,
DURATION_TYPE_UI_MAP,
durationTypeToRecurrenceRule,
} from "./eventConfig";
// eventGrouping utilities available via index.ts for external consumers
import { CalendarHeader } from "./CalendarHeader";
import { MonthView } from "./MonthView";
import { WeekView } from "./WeekView";
import { DayView } from "./DayView";
import { AgendaView } from "./AgendaView";
import { TimelineView } from "./TimelineView";
import { GroupedDayView } from "./GroupedDayView";
import { GroupedWeekView } from "./GroupedWeekView";
import { GroupedMonthView } from "./GroupedMonthView";
import { GroupedTimelineView } from "./GroupedTimelineView";
import { GroupedAgendaView } from "./GroupedAgendaView";
import { useKeyboardNavigation, KEYBOARD_SHORTCUTS } from "./useKeyboardNavigation";
import { DeleteConfirmModal, AssociatedEventsModal, KeyboardHelpModal } from "./modals";
import styles from "./Calendar.module.css";
// Unified pending change state for confirmation modal (covers both move and resize)
interface PendingChange {
event: CalendarEventInternal;
newStart: Date;
newEnd: Date;
changeType: "move" | "resize";
conflicts: CalendarEventInternal[];
// Resource change tracking (for cross-resource moves)
oldResourceId?: string;
newResourceId?: string;
}
// Event form for create/edit modal
interface EventForm {
id?: string;
title: string;
startDate: Date | null;
startTime: string; // "HH:mm" format for TimePicker
endDate: Date | null;
endTime: string; // "HH:mm" format for TimePicker
color: string;
type: CalendarEventType;
durationType: DurationType;
description: string;
location: string;
// Resource assignment
resourceId?: string;
resourceIds?: string[];
// Recurrence fields
recurrenceEndDate: Date | null;
recurrenceCount: number | null;
recurrenceDaysOfWeek: number[];
recurrenceDayOfMonth: number | null;
recurrenceNthWeekday: { nth: number; dayOfWeek: number } | null;
recurrenceInterval: number;
// Group fields (associated events)
groupId?: string;
groupIndex?: number;
groupTotal?: number;
}
// Extract the instance date from a recurring event's generated ID (format: `${parentId}-${YYYY-MM-DD}`)
const extractInstanceDate = (eventId: string, parentId: string): string | null => {
const suffix = eventId.slice(parentId.length + 1); // +1 for the "-"
// Validate it looks like a date
if (/^\d{4}-\d{2}-\d{2}$/.test(suffix)) {
return suffix;
}
return null;
};
// Add an exception date to a recurring event's parent
// Uses dayjs to parse the date string as local time to avoid timezone issues
const addRecurrenceException = (
parentEvent: CalendarEvent,
exceptionDateStr: string,
): CalendarEvent => {
// Parse as local date (noon to avoid timezone edge cases)
const exceptionDate = dayjs(exceptionDateStr).hour(12).minute(0).second(0).toDate();
const exceptions = parentEvent.recurrence?.exceptions
? [...parentEvent.recurrence.exceptions, exceptionDate]
: [exceptionDate];
return {
...parentEvent,
recurrence: {
...parentEvent.recurrence!,
exceptions,
},
};
};
const createEmptyEventForm = (): EventForm => ({
title: "",
startDate: null,
startTime: "",
endDate: null,
endTime: "",
color: "",
type: EventType.Event,
durationType: DurationType.SpecificTime,
description: "",
location: "",
resourceId: undefined,
resourceIds: [],
recurrenceEndDate: null,
recurrenceCount: null,
recurrenceDaysOfWeek: [],
recurrenceDayOfMonth: null,
recurrenceNthWeekday: null,
recurrenceInterval: 1,
});
// Helper to combine date + time into a Date object
const combineDateAndTime = (date: Date | null, time: string): Date | null => {
if (!date) return null;
if (!time) return dayjs(date).startOf("day").toDate();
const [hours, minutes] = time.split(":").map(Number);
return dayjs(date).hour(hours).minute(minutes).second(0).toDate();
};
// Helper to extract time string from a Date object
const extractTimeString = (date: Date | null): string => {
if (!date) return "";
const h = dayjs(date).hour().toString().padStart(2, "0");
const m = dayjs(date).minute().toString().padStart(2, "0");
return `${h}:${m}`;
};
// Weekday labels for recurrence selector
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// Ordinal labels for nth weekday picker
const NTH_OPTIONS = [
{ value: "1", label: "1st" },
{ value: "2", label: "2nd" },
{ value: "3", label: "3rd" },
{ value: "4", label: "4th" },
{ value: "5", label: "Last" },
];
const WEEKDAY_OPTIONS = WEEKDAY_LABELS.map((label, i) => ({
value: String(i),
label,
}));
// Style classes interface for customization
export interface CalendarStyles {
container?: CSSProperties;
header?: CSSProperties;
headerTitle?: CSSProperties;
headerNavButton?: CSSProperties;
headerTodayButton?: CSSProperties;
headerViewButton?: CSSProperties;
headerViewButtonActive?: CSSProperties;
timeLabel?: CSSProperties;
dayHeader?: CSSProperties;
dayHeaderCell?: CSSProperties;
eventChip?: CSSProperties;
timedEvent?: CSSProperties;
modal?: CSSProperties;
modalHeader?: CSSProperties;
modalContent?: CSSProperties;
// View-specific styles
monthGrid?: CSSProperties;
weekGrid?: CSSProperties;
dayGrid?: CSSProperties;
agendaList?: CSSProperties;
timelineGrid?: CSSProperties;
}
// Class names interface for customization
export interface CalendarClassNames {
container?: string;
header?: string;
viewSelector?: string;
timeLabel?: string;
dayHeader?: string;
eventChip?: string;
timedEvent?: string;
modal?: string;
}
export function Calendar({
events = [],
initialView = "month",
initialDate,
onEventClick,
onEventCreate,
onEventUpdate,
onEventDelete,
onEventConflict,
onDateChange,
onViewChange,
editable = true,
className,
defaultShowFullDay = false,
confirmMoveBeforeUpdate = false,
eventStyle = "light",
resources,
groupByResource = false,
agendaDaysToShow = 14,
timelineDaysToShow = 7,
// Event type system props
enabledEventTypes,
eventTypeOverrides,
allowCustomEventColors = false,
enabledDurationTypes,
onDurationTypeChange: _onDurationTypeChange, // eslint-disable-line @typescript-eslint/no-unused-vars
// Associated events
onGroupUpdate,
// Resource header customization
renderResourceHeader,
// Theme support
theme = "dark",
// Style customization props
styles: customStyles,
classNames,
}: CalendarProps & {
styles?: CalendarStyles;
classNames?: CalendarClassNames;
}) {
const [currentDate, setCurrentDate] = useState(() => dayjs(initialDate || new Date()));
const [view, setView] = useState<CalendarView>(initialView);
const [instanceId] = useState(() => Symbol("calendar-instance"));
const [internalEvents, setInternalEvents] = useState<CalendarEvent[]>(events);
const [showFullDay, setShowFullDay] = useState(defaultShowFullDay);
// Resource grouping state
const [activeResources, setActiveResources] = useState<Set<string>>(
() => new Set(resources?.map((r) => r.id) ?? []),
);
// Consolidated modal state - only one modal can be open at a time
type ActiveModal = "event" | "delete" | "change" | "associated" | "help" | null;
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
// Change confirmation state (move + resize + conflicts)
const [pendingChange, setPendingChange] = useState<PendingChange | null>(null);
const pendingChangeResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
// Event modal state (create/edit)
const [isEditing, setIsEditing] = useState(false);
const [eventForm, setEventForm] = useState<EventForm>(createEmptyEventForm());
// Associated events confirmation state
const [pendingAssociatedAction, setPendingAssociatedAction] = useState<{
type: "update" | "delete";
event: CalendarEvent;
groupId: string;
} | null>(null);
// Selected event for keyboard navigation (setter used in handleKeyboardEventSelect,
// value reserved for future event highlighting feature)
const [, setSelectedEvent] = useState<CalendarEventInternal | null>(null);
// Compute event type options for select dropdown
const eventTypeOptions = useMemo(
() => getEnabledEventTypes(enabledEventTypes, eventTypeOverrides),
[enabledEventTypes, eventTypeOverrides],
);
// Compute duration type options for select dropdown
const durationTypeOptions = useMemo(
() => getEnabledDurationTypes(enabledDurationTypes),
[enabledDurationTypes],
);
// Sync internal events with props
useEffect(() => {
// eslint-disable-next-line react-hooks-extra/set-state-in-effect
setInternalEvents(events);
}, [events]);
// Sync active resources when resources prop changes
useEffect(() => {
if (resources) {
// eslint-disable-next-line react-hooks-extra/set-state-in-effect
setActiveResources((prev) => {
const newIds = new Set(resources.map((r) => r.id));
// Add new resources that weren't in the previous set, keep existing selections
const updated = new Set(prev);
for (const id of newIds) {
if (!prev.has(id)) updated.add(id);
}
// Remove resources that no longer exist
for (const id of updated) {
if (!newIds.has(id)) updated.delete(id);
}
return updated;
});
}
}, [resources]);
// Calculate the visible date range for expanding recurring events
const visibleRange = useMemo(() => {
let start: Date;
let end: Date;
switch (view) {
case "day":
start = currentDate.startOf("day").toDate();
end = currentDate.endOf("day").toDate();
break;
case "week":
start = currentDate.startOf("week").toDate();
end = currentDate.endOf("week").toDate();
break;
case "month":
start = currentDate.startOf("month").startOf("week").toDate();
end = currentDate.endOf("month").endOf("week").toDate();
break;
case "agenda":
start = currentDate.startOf("day").toDate();
end = currentDate.add(agendaDaysToShow, "day").toDate();
break;
case "timeline":
start = currentDate.startOf("day").toDate();
end = currentDate.add(timelineDaysToShow, "day").toDate();
break;
default:
start = currentDate.startOf("month").toDate();
end = currentDate.endOf("month").toDate();
}
return { start, end };
}, [currentDate, view, agendaDaysToShow, timelineDaysToShow]);
// Expand recurring events and process for internal use
const processedEvents = useMemo<CalendarEventInternal[]>(() => {
const expandedEvents = expandRecurringEvents(
internalEvents,
visibleRange.start,
visibleRange.end,
);
return processEvents(expandedEvents);
}, [internalEvents, visibleRange]);
// Filter events by active resources (when resource grouping is enabled)
const filteredEvents = useMemo(() => {
if (!groupByResource || !resources || resources.length === 0) return processedEvents;
return processedEvents.filter((event) => {
// Events with a resourceId must match an active resource
if (event.resourceId) return activeResources.has(event.resourceId);
// Events with resourceIds must match at least one active resource
if (event.resourceIds && event.resourceIds.length > 0) {
return event.resourceIds.some((id) => activeResources.has(id));
}
// Events without any resource assignment are always shown
return true;
});
}, [processedEvents, groupByResource, resources, activeResources]);
// Get events for a specific resource
const getEventsForResource = useCallback(
(resourceId: string) => {
return processedEvents.filter((event) => {
if (event.resourceId === resourceId) return true;
if (event.resourceIds && event.resourceIds.includes(resourceId)) return true;
return false;
});
},
[processedEvents],
);
// Toggle hour range
const handleHourRangeToggle = useCallback(() => {
setShowFullDay((prev) => !prev);
}, []);
// Handle keyboard event select
const handleKeyboardEventSelect = useCallback((event: CalendarEventInternal | null) => {
setSelectedEvent(event);
}, []);
// Handle keyboard help toggle (press ? key)
useEffect(() => {
const handleQuestionMark = (e: KeyboardEvent) => {
if (e.key === "?" && !e.ctrlKey && !e.metaKey) {
const target = e.target as HTMLElement;
if (
target.tagName !== "INPUT" &&
target.tagName !== "TEXTAREA" &&
target.tagName !== "SELECT" &&
!target.isContentEditable
) {
e.preventDefault();
setActiveModal("help");
}
}
};
document.addEventListener("keydown", handleQuestionMark);
return () => document.removeEventListener("keydown", handleQuestionMark);
}, []);
// Map view to dayjs unit for navigation
const getNavigationUnit = (v: CalendarView): "day" | "week" | "month" => {
switch (v) {
case "day":
return "day";
case "week":
case "timeline":
return "week";
case "month":
return "month";
case "agenda":
return "week";
default:
return "month";
}
};
// Navigation handlers
const handlePrevious = useCallback(() => {
const unit = getNavigationUnit(view);
const newDate = currentDate.subtract(1, unit);
setCurrentDate(newDate);
onDateChange?.(newDate.toDate());
}, [currentDate, view, onDateChange]);
const handleNext = useCallback(() => {
const unit = getNavigationUnit(view);
const newDate = currentDate.add(1, unit);
setCurrentDate(newDate);
onDateChange?.(newDate.toDate());
}, [currentDate, view, onDateChange]);
const handleToday = useCallback(() => {
const newDate = dayjs();
setCurrentDate(newDate);
setView("day");
onDateChange?.(newDate.toDate());
onViewChange?.("day");
}, [onDateChange, onViewChange]);
const handleViewChange = useCallback(
(newView: CalendarView) => {
setView(newView);
onViewChange?.(newView);
},
[onViewChange],
);
// Handle day click from month view - switch to day view
const handleDayClick = useCallback(
(date: dayjs.Dayjs) => {
setCurrentDate(date);
setView("day");
onDateChange?.(date.toDate());
onViewChange?.("day");
},
[onDateChange, onViewChange],
);
// Event handlers - open edit modal
const handleEventClick = useCallback(
(event: CalendarEventInternal) => {
// If external handler is provided, call it
if (onEventClick) {
onEventClick(event);
} else {
// Otherwise use internal modal
// Determine resource assignment: prefer resourceIds array, fallback to single resourceId
const resourceIds = event.resourceIds;
const resourceId = event.resourceId;
setEventForm({
...createEmptyEventForm(),
id: event.id,
title: event.title,
startDate: dayjs(event.start).startOf("day").toDate(),
startTime: extractTimeString(event.start),
endDate: dayjs(event.end).startOf("day").toDate(),
endTime: extractTimeString(event.end),
color: event.color || "",
type: (event.type as CalendarEventType) || EventType.Event,
durationType: inferDurationType(event),
description: event.description || "",
location: event.location || "",
resourceId: resourceId,
resourceIds: resourceIds || (resourceId ? [resourceId] : []),
groupId: event.groupId,
groupIndex: event.groupIndex,
groupTotal: event.groupTotal,
});
setIsEditing(true);
setActiveModal("event");
}
},
[onEventClick],
);
// Handle event creation - open create modal
const handleEventCreate = useCallback(
(start: Date, end: Date) => {
// If external handler is provided, call it
if (onEventCreate) {
onEventCreate(start, end);
} else {
// Otherwise use internal modal
setEventForm({
...createEmptyEventForm(),
startDate: dayjs(start).startOf("day").toDate(),
startTime: extractTimeString(start),
endDate: dayjs(end).startOf("day").toDate(),
endTime: extractTimeString(end),
});
setIsEditing(false);
setActiveModal("event");
}
},
[onEventCreate],
);
// Handle keyboard event open (defined after handleEventClick)
const handleKeyboardEventOpen = useCallback(
(event: CalendarEventInternal) => {
handleEventClick(event);
},
[handleEventClick],
);
// Handle keyboard create event (defined after handleEventCreate)
const handleKeyboardCreateEvent = useCallback(
(date: Date) => {
const start = date;
const end = new Date(date.getTime() + 60 * 60 * 1000); // 1 hour later
handleEventCreate(start, end);
},
[handleEventCreate],
);
// Initialize keyboard navigation (after all handlers are defined)
useKeyboardNavigation({
enabled: editable && activeModal === null,
view,
currentDate,
events: processedEvents,
onDateChange: (date) => {
setCurrentDate(date);
onDateChange?.(date.toDate());
},
onViewChange: handleViewChange,
onEventSelect: handleKeyboardEventSelect,
onEventOpen: handleKeyboardEventOpen,
onCreateEvent: handleKeyboardCreateEvent,
onToday: handleToday,
});
// Show unified change confirmation modal and return a promise
const showChangeConfirmation = useCallback(
(
event: CalendarEventInternal,
newStart: Date,
newEnd: Date,
changeType: "move" | "resize",
resourceChange?: { oldResourceId?: string; newResourceId?: string },
): Promise<boolean> => {
const conflicts = checkEventConflicts(event, processedEvents, newStart, newEnd);
return new Promise((resolve) => {
setPendingChange({
event,
newStart,
newEnd,
changeType,
conflicts,
oldResourceId: resourceChange?.oldResourceId,
newResourceId: resourceChange?.newResourceId,
});
pendingChangeResolverRef.current = resolve;
setActiveModal("change");
});
},
[processedEvents],
);
// Handle change confirmation modal response
const handleChangeConfirm = useCallback(() => {
if (pendingChangeResolverRef.current) {
pendingChangeResolverRef.current(true);
pendingChangeResolverRef.current = null;
}
setActiveModal(null);
setPendingChange(null);
}, []);
const handleChangeCancel = useCallback(() => {
if (pendingChangeResolverRef.current) {
pendingChangeResolverRef.current(false);
pendingChangeResolverRef.current = null;
}
setActiveModal(null);
setPendingChange(null);
}, []);
// Core update logic shared between move and resize
const commitEventUpdate = useCallback(
(event: CalendarEventInternal) => {
// Check if this is a recurring instance that needs to be detached
if (event.recurrenceId) {
const parentId = event.recurrenceId;
const instanceDate = extractInstanceDate(event.id, parentId);
if (instanceDate) {
setInternalEvents((prev) => {
const parentIndex = prev.findIndex((e) => e.id === parentId);
if (parentIndex === -1) return prev;
// Add exception to parent
const updatedParent = addRecurrenceException(prev[parentIndex], instanceDate);
// Create standalone event with new times
const standaloneEvent: CalendarEvent = {
id: `${parentId}-exception-${instanceDate}`,
title: event.title,
start: event.start,
end: event.end,
color: event.color,
type: event.type,
description: event.description,
location: event.location,
isRecurrenceException: true,
recurrenceId: parentId,
};
const result = [...prev];
result[parentIndex] = updatedParent;
result.push(standaloneEvent);
return result;
});
onEventUpdate?.(event);
return;
}
}
// Non-recurring: update in place
setInternalEvents((prev) =>
prev.map((e) => (e.id === event.id ? { ...e, start: event.start, end: event.end } : e)),
);
onEventUpdate?.(event);
},
[onEventUpdate],
);
const handleEventUpdate = useCallback(
async (event: CalendarEventInternal) => {
// Find the original event to detect resource changes
const originalEvent = processedEvents.find((e) => e.id === event.id);
const oldResourceId = originalEvent?.resourceId || originalEvent?.resourceIds?.[0];
const newResourceId = event.resourceId || event.resourceIds?.[0];
// Only include resource change if it actually changed
const resourceChange =
oldResourceId !== newResourceId ? { oldResourceId, newResourceId } : undefined;
// When confirmMoveBeforeUpdate is on, show unified confirmation (with conflicts)
if (confirmMoveBeforeUpdate) {
const confirmed = await showChangeConfirmation(
event,
event.start,
event.end,
"move",
resourceChange,
);
if (!confirmed) return;
} else {
// No confirmation needed, but still check conflicts if handler provided
const conflicts = checkEventConflicts(event, processedEvents, event.start, event.end);
if (conflicts.length > 0 && onEventConflict) {
const proceed = await onEventConflict(event, conflicts);
if (!proceed) return;
}
}
commitEventUpdate(event);
},
[
onEventConflict,
processedEvents,
confirmMoveBeforeUpdate,
showChangeConfirmation,
commitEventUpdate,
],
);
// Save event from modal (create or update)
const handleSaveEvent = useCallback(() => {
if (!eventForm.title || !eventForm.startDate) return;
// Combine date + time into full Date objects
const uiConfig = DURATION_TYPE_UI_MAP[eventForm.durationType];
const start = combineDateAndTime(eventForm.startDate, eventForm.startTime);
const end =
uiConfig.showEndDate && eventForm.endDate
? combineDateAndTime(eventForm.endDate, eventForm.endTime)
: combineDateAndTime(eventForm.startDate, eventForm.endTime);
if (!start || !end) return;
// Generate recurrence rule from duration type
const recurrence = durationTypeToRecurrenceRule(eventForm.durationType, {
recurrenceEndDate: eventForm.recurrenceEndDate,
recurrenceCount: eventForm.recurrenceCount,
recurrenceDaysOfWeek: eventForm.recurrenceDaysOfWeek,
recurrenceDayOfMonth: eventForm.recurrenceDayOfMonth,
recurrenceNthWeekday: eventForm.recurrenceNthWeekday,
recurrenceInterval: eventForm.recurrenceInterval,
});
const buildEvent = (id: string, overrides?: Partial<CalendarEvent>): CalendarEvent => ({
id,
title: eventForm.title,
start,
end,
color: eventForm.color || undefined,
type: eventForm.type,
durationType: eventForm.durationType,
description: eventForm.description,
location: eventForm.location,
// Resource assignment: use resourceIds if multiple, otherwise single resourceId
...(eventForm.resourceIds && eventForm.resourceIds.length > 0
? { resourceIds: eventForm.resourceIds }
: eventForm.resourceId
? { resourceId: eventForm.resourceId }
: {}),
...(recurrence ? { recurrence } : {}),
...(eventForm.groupId
? {
groupId: eventForm.groupId,
groupIndex: eventForm.groupIndex,
groupTotal: eventForm.groupTotal,
}
: {}),
...overrides,
});
if (isEditing && eventForm.id) {
// Check if editing a recurring instance — find the original event in processedEvents
const originalEvent = processedEvents.find((e) => e.id === eventForm.id);
const isRecurringInstance =
originalEvent?.recurrenceId && !originalEvent.isRecurrenceException;
if (isRecurringInstance && originalEvent.recurrenceId) {
const parentId = originalEvent.recurrenceId;
const instanceDate = extractInstanceDate(eventForm.id, parentId);
if (instanceDate) {
// Detach: add exception to parent, create standalone event
setInternalEvents((prev) => {
const parentIndex = prev.findIndex((e) => e.id === parentId);
if (parentIndex === -1) return prev;
const updatedParent = addRecurrenceException(prev[parentIndex], instanceDate);
const standaloneEvent = buildEvent(`${parentId}-exception-${instanceDate}`, {
isRecurrenceException: true,
recurrenceId: parentId,
recurrence: undefined,
});
const result = [...prev];
result[parentIndex] = updatedParent;
result.push(standaloneEvent);
return result;
});
onEventUpdate?.(buildEvent(eventForm.id));
}
} else {
// Non-recurring: update in place
const updatedEvent = buildEvent(eventForm.id);
setInternalEvents((prev) => prev.map((e) => (e.id === eventForm.id ? updatedEvent : e)));
onEventUpdate?.(updatedEvent);
// If event has associated events, ask if changes should apply to them too
if (eventForm.groupId) {
setPendingAssociatedAction({
type: "update",
event: updatedEvent,
groupId: eventForm.groupId,
});
setActiveModal("associated");
}
}
} else {
// Create new event
const newEvent = buildEvent(`event-${Date.now()}`);
setInternalEvents((prev) => [...prev, newEvent]);
}
setActiveModal(null);
setEventForm(createEmptyEventForm());
}, [eventForm, isEditing, onEventUpdate, processedEvents]);
// Delete event handlers
const handleDeleteEvent = useCallback(() => {
setActiveModal("delete");
}, []);
const confirmDeleteEvent = useCallback(() => {
if (!eventForm.id) return;
// Check if this is a recurring instance
const originalEvent = processedEvents.find((e) => e.id === eventForm.id);
const isRecurringInstance = originalEvent?.recurrenceId && !originalEvent.isRecurrenceException;
if (isRecurringInstance && originalEvent.recurrenceId) {
const parentId = originalEvent.recurrenceId;
const instanceDate = extractInstanceDate(eventForm.id, parentId);
if (instanceDate) {
// Add exception to parent — don't delete the parent
// Also update internalEvents to include the exception so sync effect doesn't overwrite
setInternalEvents((prev) => {
const parentIndex = prev.findIndex((e) => e.id === parentId);
if (parentIndex === -1) return prev;
const updatedParent = addRecurrenceException(prev[parentIndex], instanceDate);
const result = [...prev];
result[parentIndex] = updatedParent;
return result;
});
// Notify parent with the modified parent event (so it can sync)
const parentEvent = internalEvents.find((e) => e.id === parentId);
if (parentEvent) {
const updatedParent = addRecurrenceException(parentEvent, instanceDate);
onEventUpdate?.(updatedParent);
}
}
} else {
// Non-recurring: delete the event
const deletedEvent = internalEvents.find((e) => e.id === eventForm.id);
setInternalEvents((prev) => prev.filter((e) => e.id !== eventForm.id));
if (deletedEvent) {
onEventDelete?.(deletedEvent);
// If event has associated events, ask if they should be deleted too
if (deletedEvent.groupId) {
setPendingAssociatedAction({
type: "delete",
event: deletedEvent,
groupId: deletedEvent.groupId,
});
setActiveModal("associated");
}
}
}
setActiveModal(null);
setEventForm(createEmptyEventForm());
}, [eventForm, internalEvents, processedEvents, onEventDelete, onEventUpdate]);
const cancelDeleteEvent = useCallback(() => {
setActiveModal("event"); // Return to event modal
}, []);
// Associated events confirmation handlers
const handleAssociatedConfirm = useCallback(() => {
if (!pendingAssociatedAction) return;
const { type, event, groupId } = pendingAssociatedAction;
if (type === "update") {
// Apply the same changes to future associated events (same groupId, date >= this event's date)
const eventDate = dayjs(event.start);
setInternalEvents((prev) =>
prev.map((e) => {
if (e.groupId === groupId && e.id !== event.id && dayjs(e.start).isAfter(eventDate)) {
return {
...e,
title: event.title,
color: event.color,
type: event.type,
durationType: event.durationType,
description: event.description,
location: event.location,
// Preserve the individual event's own date/time, id, groupIndex
};
}
return e;
}),
);
onGroupUpdate?.(groupId, {
title: event.title,
color: event.color,
type: event.type,
description: event.description,
location: event.location,
});
} else {
// Delete all future associated events with the same groupId
const eventDate = dayjs(event.start);
setInternalEvents((prev) =>
prev.filter((e) => {
if (e.groupId === groupId && e.id !== event.id && dayjs(e.start).isAfter(eventDate)) {
onEventDelete?.(e);
return false;
}
return true;
}),
);
}
setActiveModal(null);
setPendingAssociatedAction(null);
}, [pendingAssociatedAction, onGroupUpdate, onEventDelete]);
const handleAssociatedCancel = useCallback(() => {
setActiveModal(null);
setPendingAssociatedAction(null);
}, []);
// Close event modal and reset form
const handleCloseEventModal = useCallback(() => {
setActiveModal(null);
setEventForm(createEmptyEventForm());
setIsEditing(false);
}, []);
// Update form field helper
const updateFormField = <K extends keyof EventForm>(field: K, value: EventForm[K]) => {
setEventForm((prev) => ({ ...prev, [field]: value }));
};
// Set up drag and drop monitoring
useEffect(() => {
return monitorForElements({
canMonitor({ source }) {
return source.data.instanceId === instanceId;
},
onDrop() {
// Drop handling is done in individual drop targets
},
});
}, [instanceId]);
// Shared themed input styles for modal forms
const darkInputStyles =
theme === "dark"
? {
label: { color: "white" },
input: {
backgroundColor: "#2a2a3e",
borderColor: "rgba(255,255,255,0.1)",
color: "white",
},
}
: {
label: { color: "#1e1e2e" },
input: { backgroundColor: "#ffffff", borderColor: "rgba(0,0,0,0.15)", color: "#1e1e2e" },
};
const darkSelectStyles =
theme === "dark"
? {
...darkInputStyles,
dropdown: { backgroundColor: "#2a2a3e", borderColor: "rgba(255,255,255,0.1)" },
option: { color: "white" },
}
: {
...darkInputStyles,
dropdown: { backgroundColor: "#ffffff", borderColor: "rgba(0,0,0,0.15)" },
option: { color: "#1e1e2e" },
};
// Modal styles with custom overrides - theme-aware
const modalStyles =
theme === "dark"
? {
header: { backgroundColor: "#1e1e2e", ...customStyles?.modalHeader },
content: { backgroundColor: "#1e1e2e", ...customStyles?.modalContent },
title: { color: "white", fontWeight: 600 },
overlay: { backgroundColor: "rgba(0, 0, 0, 0.75)" },
}
: {
header: { backgroundColor: "#ffffff", ...customStyles?.modalHeader },
content: { backgroundColor: "#ffffff", ...customStyles?.modalContent },
title: { color: "#1e1e2e", fontWeight: 600 },
overlay: { backgroundColor: "rgba(0, 0, 0, 0.5)" },
};
// Handle event resize (updates start/end times) - separate path from move
const handleEventResize = useCallback(
async (event: CalendarEventInternal, newStart: Date, newEnd: Date) => {
const resizedEvent: CalendarEventInternal = {
...event,
start: newStart,
end: newEnd,
};
// When confirmMoveBeforeUpdate is on, show unified confirmation for resize too
if (confirmMoveBeforeUpdate) {
const confirmed = await showChangeConfirmation(resizedEvent, newStart, newEnd, "resize");
if (!confirmed) return;
} else {
// No confirmation needed, but still check conflicts if handler provided
const conflicts = checkEventConflicts(resizedEvent, processedEvents, newStart, newEnd);
if (conflicts.length > 0 && onEventConflict) {
const proceed = await onEventConflict(resizedEvent, conflicts);
if (!proceed) return;
}
}
commitEventUpdate(resizedEvent);
},
[
confirmMoveBeforeUpdate,
showChangeConfirmation,
processedEvents,
onEventConflict,
commitEventUpdate,
],
);
// Toggle a resource's active state
const toggleResource = useCallback((resourceId: string) => {
setActiveResources((prev) => {
const next = new Set(prev);
if (next.has(resourceId)) {
next.delete(resourceId);
} else {
next.add(resourceId);
}
return next;
});
}, []);
// Check if resource grouping is active for the current view
const isResourceGrouped = groupByResource && resources && resources.length > 0;
// Render the appropriate view
const renderView = () => {
const baseViewProps = {
date: currentDate,
events: filteredEvents,
onEventClick: handleEventClick,
onEventCreate: handleEventCreate,
onEventUpdate: handleEventUpdate,
onEventResize: handleEventResize,
editable,
instanceId,
};
switch (view) {
case "day":
if (isResourceGrouped) {
// Resource-grouped day view with synchronized scrolling
return (
<GroupedDayView
date={currentDate}
events={filteredEvents}
resources={resources}
activeResources={activeResources}
onEventClick={handleEventClick}
onEventCreate={handleEventCreate}
onEventUpdate={handleEventUpdate}
onEventResize={handleEventResize}
editable={editable}
instanceId={instanceId}
showFullDay={showFullDay}
eventStyle={eventStyle}
renderResourceHeader={renderResourceHeader}
getEventsForResource={getEventsForResource}
/>
);
}
return <DayView {...baseViewProps} showFullDay={showFullDay} eventStyle={eventStyle} />;
case "week":
if (isResourceGrouped) {
// Resource-grouped week view with vertically stacked sections
return (
<GroupedWeekView
date={currentDate}
events={filteredEvents}
resources={resources}
activeResources={activeResources}
onEventClick={handleEventClick}
onEventCreate={handleEventCreate}
onEventUpdate={handleEventUpdate}
onEventResize={handleEventResize}
editable={editable}
instanceId={instanceId}
showFullDay={showFullDay}
eventStyle={eventStyle}
renderResourceHeader={renderResourceHeader}
getEventsForResource={getEventsForResource}
/>
);
}
return <WeekView {...baseViewProps} showFullDay={showFullDay} eventStyle={eventStyle} />;
case "agenda":
if (isResourceGrouped) {
// Resource-grouped agenda view with vertically stacked sections
return (
<GroupedAgendaView
date={currentDate}
events={filteredEvents}
resources={resources}
activeResources={activeResources}
onEventClick={handleEventClick}
instanceId={instanceId}
daysToShow={agendaDaysToShow}
renderResourceHeader={renderResourceHeader}
getEventsForResource={getEventsForResource}
/>
);
}
return <AgendaView {...baseViewProps} daysToShow={agendaDaysToShow} />;
case "timeline":
if (isResourceGrouped) {
// Resource-grouped timeline view with vertically stacked sections
return (
<GroupedTimelineView
date={currentDate}
events={filteredEvents}
resources={resources}
activeResources={activeResources}
onEventClick={handleEventClick}
onEventCreate={handleEventCreate}
onEventUpdate={handleEventUpdate}
editable={editable}
instanceId={instanceId}
showFullDay={showFullDay}
daysToShow={timelineDaysToShow}
eventStyle={eventStyle}
renderResourceHeader={renderResourceHeader}
getEventsForResource={getEventsForResource}
/>
);
}
return (
<TimelineView
{...baseViewProps}
showFullDay={showFullDay}
daysToShow={timelineDaysToShow}
eventStyle={eventStyle}
/>
);
case "month":
default:
if (isResourceGrouped) {
// Resource-grouped month view with vertically stacked sections
return (
<GroupedMonthView
date={currentDate}
events={filteredEvents}
resources={resources}
activeResources={activeResources}
onEventClick={handleEventClick}
onEventCreate={handleEventCreate}
onEventUpdate={handleEventUpdate}
onDayClick={handleDayClick}
editable={editable}
instanceId={instanceId}
eventStyle={eventStyle}
renderResourceHeader={renderResourceHeader}
getEventsForResource={getEventsForResource}
/>
);
}
return <MonthView {...baseViewProps} onDayClick={handleDayClick} eventStyle={eventStyle} />;
}
};
return (
<div
className={`${styles.calendarContainer} ${className || ""} ${classNames?.container || ""}`}
style={customStyles?.container}
data-testid="calendar-container"
data-theme={theme}
>
<CalendarHeader
date={currentDate}
view={view}
onPrevious={handlePrevious}
onNext={handleNext}
onToday={handleToday}
onViewChange={handleViewChange}
showFullDay={showFullDay}
onHourRangeToggle={handleHourRangeToggle}
onCreateEvent={
editable
? () => {
const now = dayjs();
const start = now.minute(0).second(0).add(1, "hour").toDate();
const end = now.minute(0).second(0).add(2, "hour").toDate();
handleEventCreate(start, end);
}
: undefined
}
/>
{/* Resource selector chips */}
{isResourceGrouped && (
<div className={styles.resourceSelector}>
{resources.map((resource) => (
<button
type="button"
key={resource.id}
className={`${styles.resourceChip} ${activeResources.has(resource.id) ? styles.resourceChipActive : ""}`}
onClick={() => toggleResource(resource.id)}
style={{
borderColor: resource.color || "#4285F4",
...(activeResources.has(resource.id)
? {
backgroundColor: `${resource.color || "#4285F4"}22`,
}
: {}),
}}
>
<span
className={styles.resourceChipDot}
style={{ backgroundColor: resource.color || "#4285F4" }}
/>
{resource.name}
</button>
))}
</div>
)}
{renderView()}
{/* Unified Change Confirmation Modal (move/resize + conflicts) */}
<Modal
opened={activeModal === "change"}
onClose={handleChangeCancel}
title={`Confirm Event ${pendingChange?.changeType === "resize" ? "Resize" : "Move"}`}
centered
size="sm"
styles={modalStyles}
>
<Stack gap="md">
{pendingChange && (
<>
<Text c="white" size="sm">
{pendingChange.changeType === "resize" ? "Resize" : "Move"} "
{pendingChange.event.title}" to:
</Text>
<Text c="dimmed" size="sm">
{dayjs(pendingChange.newStart).format("dddd, MMMM D, YYYY")}
</Text>
<Text c="dimmed" size="sm">
{dayjs(pendingChange.newStart).format("h:mm A")} -{" "}
{dayjs(pendingChange.newEnd).format("h:mm A")}
</Text>
{/* Resource change display (only when resource actually changed) */}
{pendingChange.oldResourceId !== pendingChange.newResourceId &&
(pendingChange.oldResourceId || pendingChange.newResourceId) && (
<Text c={theme === "dark" ? "white" : "#1e1e2e"} size="sm" mt="xs">
{(() => {
const oldResource = resources?.find(
(r) => r.id === pendingChange.oldResourceId,
);
const newResource = resources?.find(
(r) => r.id === pendingChange.newResourceId,
);
const oldName = oldResource?.name || "Unassigned";
const newName = newResource?.name || "Unassigned";
return `Move from ${oldName} to ${newName}`;
})()}
</Text>
)}
{pendingChange.conflicts.length > 0 && (
<>
<Text c="yellow" size="sm" fw={600} mt="xs">
Conflicts with:
</Text>
<Box>
{pendingChange.conflicts.map((conflict) => (
<Group key={conflict.id} gap="sm" mb={8}>
<ColorSwatch
color={resolveEventColor(conflict, eventTypeOverrides)}
size={16}
/>
<Text c="white" size="sm">
{conflict.title} ({dayjs(conflict.start).format("h:mm A")} -{" "}
{dayjs(conflict.end).format("h:mm A")})
</Text>
</Group>
))}
</Box>
</>
)}
</>
)}
<Group justify="flex-end" mt="md">
<Button variant="subtle" color="gray" onClick={handleChangeCancel}>
Cancel
</Button>
<Button
color={pendingChange?.conflicts.length ? "yellow" : undefined}
onClick={handleChangeConfirm}
>
{pendingChange?.conflicts.length
? `${pendingChange.changeType === "resize" ? "Resize" : "Move"} Anyway`
: `Confirm ${pendingChange?.changeType === "resize" ? "Resize" : "Move"}`}
</Button>
</Group>
</Stack>
</Modal>
{/* Event Create/Edit Modal */}
<Modal
opened={activeModal === "event"}
onClose={handleCloseEventModal}
title={isEditing ? "Edit Event" : "Create Event"}
centered
size="lg"
styles={modalStyles}
>
<Stack gap="md">
{/* Title */}
<TextInput
label="Event Title"
placeholder="Enter event title"
value={eventForm.title}
onChange={(e) => updateFormField("title", e.target.value)}
required
styles={darkInputStyles}
/>
{/* Event Type + Duration Type side by side */}
<Group grow>
<Select
label="Event Type"
placeholder="Select type"
data={eventTypeOptions}
value={eventForm.type}
onChange={(value) => {
const newType = (value as CalendarEventType) || EventType.Event;
updateFormField("type", newType);
// Auto-clear color so type default applies
if (!eventForm.color) {
// Color already empty, will resolve from type
}
}}
styles={darkSelectStyles}
/>
<Select
label="Duration Type"
placeholder="Select duration"
data={durationTypeOptions}
value={eventForm.durationType}
onChange={(value) => {
const newDurationType = (value || DurationType.SpecificTime) as DurationType;
const newUiConfig = DURATION_TYPE_UI_MAP[newDurationType];
setEventForm((prev) => ({
...prev,
durationType: newDurationType,
// Clear recurrence fields for non-recurring types
...(!newUiConfig.generatesRecurrence
? {
recurrenceEndDate: null,
recurrenceCount: null,
recurrenceDaysOfWeek: [],
recurrenceDayOfMonth: null,
recurrenceNthWeekday: null,
recurrenceInterval: 1,
}
: {}),
}));
}}
styles={darkSelectStyles}
/>
</Group>
{/* Resource Assignment - only show if resources are configured */}
{(resources?.length ?? 0) > 0 && (
<MultiSelect
label="Assign to Resources"
placeholder="Select resources (optional)"
data={(resources ?? []).map((r) => ({
value: r.id,
label: r.name,
}))}
value={eventForm.resourceIds || []}
onChange={(values) => {
setEventForm((prev) => ({
...prev,
resourceIds: values,
// Also update single resourceId for backwards compatibility
resourceId: values.length === 1 ? values[0] : undefined,
}));
}}
clearable
searchable
styles={darkSelectStyles}
/>
)}
{/* Dynamic Date/Time Controls based on durationType */}
{(() => {
const uiConfig = DURATION_TYPE_UI_MAP[eventForm.durationType];
return (
<>
{/* Date pickers */}
{uiConfig.showDatePickers && (
<Group grow>
<DatePickerInput
label={uiConfig.showEndDate ? "Start Date" : "Date"}
placeholder="Select date"
value={eventForm.startDate}
onChange={(date) => {
const d = date as Date | null;
updateFormField("startDate", d);
if (!uiConfig.showEndDate) {
updateFormField("endDate", d);
}
}}
required
valueFormat="MM/DD/YYYY"
styles={darkInputStyles}
/>
{uiConfig.showEndDate && (
<DatePickerInput
label="End Date"
placeholder="Select end date"
value={eventForm.endDate}
onChange={(date) => updateFormField("endDate", date as Date | null)}
valueFormat="MM/DD/YYYY"
styles={darkInputStyles}
/>
)}
</Group>
)}
{/* Time pickers (12h format with dropdown) */}
{uiConfig.showTimePickers && (
<Group grow>
<Box>
<Text size="sm" mb={4} c="white">
Start Time
</Text>
<TimePicker
value={eventForm.startTime}
onChange={(val) => updateFormField("startTime", val || "")}
format="12h"
withDropdown
styles={darkInputStyles}
/>
</Box>
<Box>
<Text size="sm" mb={4} c="white">
End Time
</Text>
<TimePicker
value={eventForm.endTime}
onChange={(val) => updateFormField("endTime", val || "")}
format="12h"
withDropdown
styles={darkInputStyles}
/>
</Box>
</Group>
)}
{/* Recurrence controls */}
{uiConfig.generatesRecurrence && (
<Stack gap="sm">
<Text size="sm" fw={600} c="white">
Recurrence
</Text>
{/* Weekday selector */}
{uiConfig.showWeekdaySelector && (
<Box>
<Text size="xs" mb={4} c="dimmed">
Days of week
</Text>
<Group gap={4}>
{WEEKDAY_LABELS.map((label, i) => (
<Chip
key={i}
checked={eventForm.recurrenceDaysOfWeek.includes(i)}
onChange={() => {
setEventForm((prev) => {
const days = prev.recurrenceDaysOfWeek.includes(i)
? prev.recurrenceDaysOfWeek.filter((d) => d !== i)
: [...prev.recurrenceDaysOfWeek, i];
return { ...prev, recurrenceDaysOfWeek: days };
});
}}
size="xs"
variant="filled"
>
{label}
</Chip>
))}
</Group>
</Box>
)}
{/* Day of month selector */}
{uiConfig.showMonthDaySelector && (
<NumberInput
label="Day of month"
placeholder="e.g. 15"
value={eventForm.recurrenceDayOfMonth ?? ""}
onChange={(val) =>
updateFormField(
"recurrenceDayOfMonth",
typeof val === "number" ? val : null,
)
}
min={1}
max={31}
styles={darkInputStyles}
/>
)}
{/* Nth weekday picker */}
{uiConfig.showNthWeekdayPicker && (
<Group grow>
<Select
label="Ordinal"
placeholder="Which"
data={NTH_OPTIONS}
value={
eventForm.recurrenceNthWeekday
? String(eventForm.recurrenceNthWeekday.nth)
: null
}
onChange={(val) => {
const nth = val ? parseInt(val) : 1;
setEventForm((prev) => ({
...prev,
recurrenceNthWeekday: {
nth,
dayOfWeek: prev.recurrenceNthWeekday?.dayOfWeek ?? 1,
},
}));
}}
styles={darkSelectStyles}
/>
<Select
label="Day"
placeholder="Weekday"
data={WEEKDAY_OPTIONS}
value={
eventForm.recurrenceNthWeekday
? String(eventForm.recurrenceNthWeekday.dayOfWeek)
: null
}
onChange={(val) => {
const dayOfWeek = val ? parseInt(val) : 1;
setEventForm((prev) => ({
...prev,
recurrenceNthWeekday: {
nth: prev.recurrenceNthWeekday?.nth ?? 1,
dayOfWeek,
},
}));
}}
styles={darkSelectStyles}
/>
</Group>
)}
{/* Interval input */}
{uiConfig.showIntervalInput && (
<NumberInput
label="Repeat every N periods"
placeholder="1"
value={eventForm.recurrenceInterval}
onChange={(val) =>
updateFormField("recurrenceInterval", typeof val === "number" ? val : 1)
}
min={1}
max={52}
styles={darkInputStyles}
/>
)}
{/* Recurrence end */}
{uiConfig.showRecurrenceEnd && (
<Group grow>
<DatePickerInput
label="Recurrence End Date"
placeholder="Optional end date"
value={eventForm.recurrenceEndDate}
onChange={(date) =>
updateFormField("recurrenceEndDate", date as Date | null)
}
clearable
valueFormat="MM/DD/YYYY"
styles={darkInputStyles}
/>
<NumberInput
label="Or after N occurrences"
placeholder="Optional"
value={eventForm.recurrenceCount ?? ""}
onChange={(val) =>
updateFormField("recurrenceCount", typeof val === "number" ? val : null)
}
min={1}
styles={darkInputStyles}
/>
</Group>
)}
</Stack>
)}
</>
);
})()}
{/* Location */}
<TextInput
label="Location"
placeholder="Enter location (optional)"
value={eventForm.location}
onChange={(e) => updateFormField("location", e.target.value)}
styles={darkInputStyles}
/>
{/* Description */}
<Textarea
label="Description"
placeholder="Enter description (optional)"
value={eventForm.description}
onChange={(e) => updateFormField("description", e.target.value)}
rows={3}
styles={darkInputStyles}
/>
{/* Color Override (optional) */}
{allowCustomEventColors && (
<Box>
<Text size="sm" mb={8} c="white">
Custom Color Override
</Text>
<Group gap={8}>
{DEFAULT_EVENT_COLORS.map((color) => (
<ColorSwatch
key={color}
color={color}
onClick={() => updateFormField("color", color)}
style={{
cursor: "pointer",
border: eventForm.color === color ? "2px solid white" : "none",
}}
/>
))}
{eventForm.color && (
<Button
variant="subtle"
size="compact-xs"
color="gray"
onClick={() => updateFormField("color", "")}
>
Reset to type default
</Button>
)}
</Group>
</Box>
)}
{/* Action buttons */}
<Group justify="space-between" mt="md">
{isEditing && (
<Button variant="subtle" color="red" onClick={handleDeleteEvent}>
Delete Event
</Button>
)}
<Group ml="auto" gap="sm">
<Button variant="subtle" onClick={handleCloseEventModal}>
Cancel
</Button>
<Button onClick={handleSaveEvent} disabled={!eventForm.title || !eventForm.startDate}>
{isEditing ? "Save Changes" : "Create Event"}
</Button>
</Group>
</Group>
</Stack>
</Modal>
{/* Delete Confirmation Modal - rendered after Edit modal for proper z-stacking */}
<DeleteConfirmModal
opened={activeModal === "delete"}
onClose={cancelDeleteEvent}
onConfirm={confirmDeleteEvent}
eventTitle={eventForm.title}
customStyles={customStyles}
/>
{/* Associated Events Confirmation Modal */}
<AssociatedEventsModal
opened={activeModal === "associated"}
onClose={handleAssociatedCancel}
onConfirm={handleAssociatedConfirm}
actionType={pendingAssociatedAction?.type ?? null}
customStyles={customStyles}
/>
{/* Keyboard Shortcuts Help Modal */}
<KeyboardHelpModal
opened={activeModal === "help"}
onClose={() => setActiveModal(null)}
shortcuts={KEYBOARD_SHORTCUTS}
customStyles={customStyles}
/>
{/* Keyboard Help Button (floating) */}
<button
type="button"
className={styles.keyboardHelpButton}
onClick={() => setActiveModal("help")}
title="Keyboard shortcuts (?)"
aria-label="Show keyboard shortcuts"
>
<IoHelpCircleOutline size={20} />
</button>
</div>
);
}
// Export everything from types for convenience
// eslint-disable-next-line react-refresh/only-export-components
export * from "./types";
export { CalendarHeader } from "./CalendarHeader";
export { MonthView } from "./MonthView";
export { WeekView } from "./WeekView";
export { DayView } from "./DayView";
export { AgendaView } from "./AgendaView";
export { TimelineView } from "./TimelineView";
export { EventChip, TimedEvent } from "./EventChip";