Event Schduler
Building an Interactive Calendar Dashboard: Leveraging Browser APIs for Drag-and-Drop and Real-Time Event Management
Introduction
This calendar dashboard is a Next.js app for managing auditorium events. It supports drag-and-drop, real-time updates, and multiple views. This post covers the implementation, focusing on browser APIs that enable these features.
What It Does
The dashboard provides:
- Multi-resource calendar views (monthly, weekly, daily)
- Drag-and-drop event scheduling
- Event resizing
- Real-time conflict detection
- Event details dock for quick editing
- Table and list views with filtering
- Integration with cinema scheduling systems
Architecture Overview
Built with:
- Next.js 16 (App Router)
- React 19
- TypeScript
- TanStack Table for data tables
- Radix UI for accessible components
- Tailwind CSS for styling
Browser APIs in Action
1. HTML5 Drag and Drop API
The calendar uses the native Drag and Drop API for moving events.
const handleDragStart = useCallback(
(event: Event, resourceId: string) => {
setDragItem({ eventId: event.id, resourceId, event });
handleEventSelect({ eventId: event.id, resourceId });
},
[handleEventSelect],
);How it works:
draggableattribute enables draggingonDragStartcaptures the event being draggedonDragOvertracks the drop targetonDropcompletes the move
<div
draggable={isEditable}
onClick={(e) => {
e.stopPropagation();
if (e.detail === 1 && !isDragging && !isResizing) {
onEventSelect({
eventId: event.id,
resourceId: resource.id,
});
}
}}
onDragStart={(e) => {
if (!isEditable) {
e.preventDefault();
return;
}
const target = e.target as HTMLElement;
if (target.closest(".resize-handle")) {
e.preventDefault();
return;
}
if (!isResizing) {
onDragStart(event, resource.id);
} else {
e.preventDefault();
}
}}
onDragEnd={onDragEnd}Custom drag image:
onDragStart={(e) => {
console.log("[CalendarGrid] Draggable item onDragStart", {
itemId: item.id,
type: item.type,
name: item.name,
});
handleNewItemDragStart(item.type, item.name, item.color);
const img = new Image();
img.src =
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
e.dataTransfer.setDragImage(img, 0, 0);
}}2. Mouse Events API for Resizing
Event resizing uses mouse events to track cursor position:
const handleResizeStart = useCallback(
(event: Event, resourceId: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setResizeItem({
eventId: event.id,
resourceId,
startDay: event.startDay,
});
setResizeTarget(event.endDay);
resizeTargetRef.current = event.endDay;
const handleMouseMove = (moveEvent: MouseEvent) => {
const dayCells = document.querySelectorAll("[data-day-index]");
let targetDay: number | null = null;
dayCells.forEach((cell) => {
const rect = cell.getBoundingClientRect();
if (
moveEvent.clientX >= rect.left &&
moveEvent.clientX <= rect.right &&
moveEvent.clientY >= rect.top &&
moveEvent.clientY <= rect.bottom
) {
const dayIndex = parseInt(
cell.getAttribute("data-day-index") || "-1",
);
if (dayIndex >= 0 && dayIndex < days.length) {
targetDay = days[dayIndex];
}
}
});
if (targetDay !== null) {
setResizeTarget(targetDay);
resizeTargetRef.current = targetDay;
}
};
const handleMouseUp = () => {
const finalTarget = resizeTargetRef.current;
setResizeItem((currentResizeItem) => {
if (currentResizeItem && finalTarget !== null && onEventResize) {
onEventResize(
currentResizeItem.eventId,
currentResizeItem.resourceId,
finalTarget,
);
}
return null;
});
setResizeTarget(null);
resizeTargetRef.current = null;
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[days, onEventResize],
);Key points:
getBoundingClientRect()for cell positionsclientX/clientYfor cursor tracking- Document-level listeners for continuous tracking
- Cleanup on mouseup
3. Element.getBoundingClientRect()
Used to determine which day cell the cursor is over:
dayCells.forEach((cell) => {
const rect = cell.getBoundingClientRect();
if (
moveEvent.clientX >= rect.left &&
moveEvent.clientX <= rect.right &&
moveEvent.clientY >= rect.top &&
moveEvent.clientY <= rect.bottom
) {
const dayIndex = parseInt(
cell.getAttribute("data-day-index") || "-1",
);
if (dayIndex >= 0 && dayIndex < days.length) {
targetDay = days[dayIndex];
}
}
});4. LocalStorage API
Stores authentication tokens:
const getAuthToken = () => {
if (typeof window !== "undefined") {
return localStorage.getItem("authToken");
return process.env.NEXT_PUBLIC_AUTH_TOKEN || null;
};5. Intersection Observer API
Used for infinite scroll in filter dropdowns:
// IntersectionObserver for infinite scroll
useEffect(() => {
const observer = new IntersectionObserver(6. MediaQueryList API
Responsive design using matchMedia:
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mql = window.matchMedia('(max-width: 768px)')
const onChange = () => {
setIsMobile(mql.matches)
}
mql.addEventListener('change', onChange)
setIsMobile(mql.matches)
return () => mql.removeEventListener('change', onChange)
}, [])
return isMobile
}The Event Details Dock: A Pinned Interface
The event details dock is a bottom-fixed panel that appears when an event is selected:
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-background/95 backdrop-blur-md border border-border rounded-xl shadow-2xl z-50 w-auto max-w-[1000px]">
<div className="px-4 py-2">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 min-w-0">
<Input
id="customer-name"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="Customer name"
className="h-8 w-[140px] text-sm"
/>
<Input
id="customer-email"
type="email"
value={customerEmail}
onChange={(e) => {
setCustomerEmail(e.target.value);
if (emailError) setEmailError(false);
}}
placeholder="Email"
className={`h-8 w-[160px] text-sm ${
emailError
? "border-destructive focus-visible:ring-destructive"
: ""
}`}
/>
<PhoneInput
id="customer-phone"
value={customerMobile || undefined}
onChange={(value) => setCustomerMobile(value || "")}
placeholder="Phone"
className="h-8 w-[140px] text-sm"
/>
<Input
id="remarks"
value={remarks}
onChange={(e) => setRemarks(e.target.value)}
placeholder="Remarks"
className="h-8 w-[140px] text-sm"
/>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
onClick={handleSave}
disabled={isSaving}
size="sm"
className="h-8 text-sm"
>
{isSaving ? "Saving..." : "Save"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
{onDelete && (
<DropdownMenuItem
onClick={handleDelete}
disabled={isDeleting}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{isDeleting ? "Deleting..." : "Delete"}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
</div>
</div>Features:
- Fixed positioning with CSS transforms for centering
- Backdrop blur for visual separation
- Inline editing without opening dialogs
- Auto-save on change
Conflict Detection System
Prevents overlapping events using date/time calculations:
const checkScheduleOverlap = (
resources: Resource[],
resourceId: string,
currentDate: Date,
startDate: Date,
endDate: Date,
startTime: string,
endTime: string,
excludeEventId?: string,
): boolean => {
const resource = resources.find((r) => r.id === resourceId);
if (!resource) return false;
const parseTime = (timeStr: string): number => {
const parts = timeStr.split(":");
const hours = parseInt(parts[0] || "0", 10);
const minutes = parseInt(parts[1] || "0", 10);
return hours * 60 + minutes;
};
const normalizeDate = (date: Date): Date => {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
};
const normalizedStartDate = normalizeDate(startDate);
const normalizedEndDate = normalizeDate(endDate);
for (const event of resource.events) {
if (excludeEventId && event.id === excludeEventId) continue;
if (!event.id.startsWith("schedule-")) continue;
const eventStartDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth(),
event.startDay,
);
const eventEndDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth(),
event.endDay,
);
const normalizedEventStartDate = normalizeDate(eventStartDate);
const normalizedEventEndDate = normalizeDate(eventEndDate);
const dateOverlaps =
normalizedStartDate <= normalizedEventEndDate &&
normalizedEndDate >= normalizedEventStartDate;
if (dateOverlaps) {
const eventStartTime = parseTime(event.startTime || "00:00");
const eventEndTime = parseTime(event.endTime || "23:59");
const newStartTime = parseTime(startTime);
const newEndTime = parseTime(endTime);
const timeOverlaps =
newStartTime < eventEndTime && newEndTime > eventStartTime;
if (timeOverlaps) {
return true;
}
}
}
return false;
};Data Transformation Pipeline
Converts API data into calendar-friendly formats:
const transformApiDataToResources = (
records: CalendarEventRecord[],
currentDate: Date,
auditoriums: Auditorium[],
): Resource[] => {
const auditoriumMap = new Map<string, Auditorium>();
auditoriums.forEach((auditorium) => {
if (auditorium.audi_is_active === "Y") {
auditoriumMap.set(auditorium.auditorium_id.toString(), auditorium);
}
});
const resourceMap = new Map<string, Resource>();
auditoriumMap.forEach((auditorium) => {
resourceMap.set(auditorium.auditorium_id.toString(), {
id: auditorium.auditorium_id.toString(),
name: auditorium.audi_name,
events: [],
});
});
records.forEach((record) => {
if (record.sce_is_active !== "Y") {
return;
}
const auditoriumId = record.ss_auditorium_id?.toString() || "unknown";
if (!resourceMap.has(auditoriumId)) {
return;
}
const resource = resourceMap.get(auditoriumId)!;
const startDateStr = record.event_start_date.trim();
const endDateStr = record.event_end_date.trim();
const startDate = new Date(startDateStr + "T00:00:00");
const endDate = new Date(endDateStr + "T23:59:59");
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return;
}
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
const startYear = startDate.getFullYear();
const startMonth = startDate.getMonth();
const startDay = startDate.getDate();
const endYear = endDate.getFullYear();
const endMonth = endDate.getMonth();
const endDay = endDate.getDate();
let eventStartDay: number | null = null;
let eventEndDay: number | null = null;
if (
startYear === currentYear &&
startMonth === currentMonth &&
endYear === currentYear &&
endMonth === currentMonth
) {
eventStartDay = startDay;
eventEndDay = endDay;
} else if (
startYear === currentYear &&
startMonth === currentMonth &&
(endYear > currentYear || endMonth > currentMonth)
) {
eventStartDay = startDay;
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
eventEndDay = daysInMonth;
} else if (
(startYear < currentYear || startMonth < currentMonth) &&
endYear === currentYear &&
endMonth === currentMonth
) {
eventStartDay = 1;
eventEndDay = endDay;
} else if (
(startYear < currentYear || startMonth < currentMonth) &&
(endYear > currentYear || endMonth > currentMonth)
) {
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
eventStartDay = 1;
eventEndDay = daysInMonth;
}
if (eventStartDay !== null && eventEndDay !== null) {
const colorCode = record.event_color_code?.toLowerCase() || "";
const color = colorMap[colorCode] || defaultColor;
const eventType =
record.event_description || record.event_name || "Event";
resource.events.push({
id: record.cal_event_id.toString(),
name: record.event_name,
type: eventType,
startDay: eventStartDay,
endDay: eventEndDay,
startTime: record.event_start_time,
endTime: record.event_end_time,
color,
event_color_code: record.event_color_code || undefined,
event_status: getEventStatusName(record.event_status),
isEditable: true,
});
}
});
return Array.from(resourceMap.values());
};Performance Optimizations
- useMemo for expensive calculations
- useCallback for stable function references
- Virtual scrolling for large datasets
- Debounced search queries
- Lazy loading of non-critical components
Benefits
- Native browser APIs reduce dependencies
- Smooth drag-and-drop interactions
- Real-time conflict detection
- Responsive across devices
- Accessible via Radix UI primitives
- Type-safe with TypeScript
Conclusion
This dashboard uses native browser APIs to deliver a responsive, interactive calendar. The Drag and Drop API, mouse events, and DOM measurement APIs enable smooth interactions. The event details dock provides quick editing, and conflict detection prevents scheduling errors.
By leveraging these APIs, the app remains performant and maintainable while providing a modern user experience.