feat: improve overlapping events display in weekly calendar view (#24880)

* feat: improve overlapping events display in weekly calendar view

- Add cascading layout algorithm for overlapping events
- First event at 80% width, subsequent events offset by 8%
- Implement hover behavior to bring events to front (z-index 100)
- Extract overlap logic into reusable utility functions
- Add comprehensive unit tests for overlap detection and layout calculation
- Sort events by start time, then by duration for consistent rendering

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: add weekly calendar playground page for testing overlapping events

- Create comprehensive playground page with 8 test scenarios
- Include two overlapping, three cascading, non-overlapping, same start time, chain overlaps, dense day, touching events, and mixed statuses
- Add focused view with scenario selector and grid view for side-by-side comparison
- Update playground index to include weekly calendar link
- Each scenario includes description, expected behavior, and collapsible event data

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* update base props

* remove unneccessary things

* fix: isolate Calendar store instances to prevent shared state

- Refactor useCalendarStore to use Zustand's createStore with React Context
- Each Calendar component now creates its own isolated store instance
- Maintains backward compatibility with global store fallback
- Fixes issue where multiple Calendar instances on same page shared state

This allows the playground page to render multiple Calendar instances
without state conflicts between them.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: prevent event overflow in dense overlap scenarios

- Dynamically compress offset step based on overlap group size
- Ensure leftOffset + width never exceeds 100% to prevent bleeding into next day
- Add width clamping as safety guard against rounding errors
- Add 3 new tests for overflow prevention with dense event scenarios

Fixes issue where 10+ overlapping events would cascade beyond day boundary
and bleed into the next day's column. The algorithm now calculates the
maximum safe step size per overlap group while maintaining visual cascade.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* remove global store

* fix: comprehensive overflow prevention with safety margin and CSS fix

- Add safetyMarginPercent (0.5%) to prevent rounding and CSS box model overflow
- Implement floor3 rounding for width to guarantee left + width <= 100 - safetyMargin
- Calculate width from rounded left offset to avoid rounding mismatch
- Remove inset-x-1 class conflict that was setting both left and right positioning
- Change from marginLeft to left positioning for proper control
- Add test for 20+ overlapping events to verify no overflow
- Update existing tests to verify safety margin is respected

This fixes the slight overflow issue reported by the user where events were
still bleeding into adjacent day columns despite the initial dynamic step
compression fix. The root cause was a combination of:
1. CSS conflict: inset-x-1 class setting both left and right positioning
2. Rounding mismatch: width calculated from unrounded left offset
3. No safety margin for CSS box model effects (borders, padding)

All 21 tests now pass including new safety margin verification.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: update Dense Day scenario to 20+ events

- Increase from 11 to 21 overlapping events in Dense Day scenario
- Update title from '10+ Events' to '20+ Events'
- Add 10 more diverse events with staggered start times
- Better stress test for overflow prevention with safety margin

This provides a more comprehensive test case for the overflow prevention
fix and matches the user's request for 20+ events instead of 10+.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* WIP

* feat: enhance event tooltip to show full event details

- Replace simple title-only tooltip with rich content tooltip
- Display event title, time range, description, and status
- Add color indicator matching the event's visual style
- Set min-width (200px) and max-width (300px) for better readability
- Use inverted theme colors for better contrast in tooltip

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* faster animation

* feat: position tooltip based on day of week

- Show tooltip on right side for Monday-Thursday (days 1-4)
- Show tooltip on left side for Friday-Sunday (days 5-0)
- Calculate day of week using dayjs from event start date

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* add margin for tooltip

* feat: add event duration layout test scenario

- Add new scenario with events of varying durations (3, 7, 15, 20, 30, 53 minutes)
- Test layout logic where eventDuration > 30 changes flex direction
- Events ≤30min show horizontal layout (title and time inline)
- Events >30min show vertical layout (title and time stacked)

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* update styles

* feat: add minimum height of 15px for events

- Ensure very short events (e.g., 3 minutes) are still visible
- Use CSS max() to apply minimum height while respecting duration-based height
- Prevents events from becoming too small to interact with

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* update playground

* do not mutate

* pre-compute some values

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Sean Brydon <sean@cal.com>
This commit is contained in:
authored and GitHub committed 2025-11-05 15:56:24 +00:00
1 parent c48f2043f2
commit b01cb27c56
9 files changed
+1270 -168

No files matched your search

@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef } from "react";
import classNames from "@calcom/ui/classNames";
import { useCalendarStore } from "../state/store";
import { CalendarStoreContext, createCalendarStore, useCalendarStore } from "../state/store";
import "../styles/styles.css";
import type { CalendarComponentProps } from "../types/state";
import { getDaysBetweenDates, getHoursToDisplay } from "../utils";
@@ -16,7 +16,7 @@ import { HorizontalLines } from "./horizontalLines";
import { Spinner } from "./spinner/Spinner";
import { VerticalLines } from "./verticalLines";
export function Calendar(props: CalendarComponentProps) {
function CalendarInner(props: CalendarComponentProps) {
const container = useRef<HTMLDivElement | null>(null);
const containerNav = useRef<HTMLDivElement | null>(null);
const containerOffset = useRef<HTMLDivElement | null>(null);
@@ -44,7 +44,7 @@ export function Calendar(props: CalendarComponentProps) {
const numberOfGridStopsPerDay = hours.length * usersCellsStopsPerHour;
const hourSize = 58;
// Initalise State on initial mount
// Initalise State on initial mount and when props change
useEffect(() => {
initialState(props);
}, [props, initialState]);
@@ -170,6 +170,27 @@ export function Calendar(props: CalendarComponentProps) {
);
}
export function Calendar(props: CalendarComponentProps) {
const storeRef = useRef<ReturnType<typeof createCalendarStore> | null>(null);
if (!storeRef.current) {
storeRef.current = createCalendarStore();
storeRef.current.getState().initState(props);
}
useEffect(() => {
if (storeRef.current) {
storeRef.current.getState().initState(props);
}
}, [props]);
return (
<CalendarStoreContext.Provider value={storeRef.current}>
<CalendarInner {...props} />
</CalendarStoreContext.Provider>
);
}
/** @todo Will be removed once we have mobile support */
const MobileNotSupported = ({ children }: { children: React.ReactNode }) => {
return (
@@ -12,10 +12,11 @@ type EventProps = {
eventDuration: number;
onEventClick?: (event: CalendarEvent) => void;
disabled?: boolean;
isHovered?: boolean;
};
const eventClasses = cva(
"group flex h-full w-full overflow-x-hidden overflow-y-auto rounded-[6px] px-[6px] text-xs leading-5 opacity-80 border-default font-medium",
"group flex h-full w-full overflow-hidden rounded-[6px] px-[6px] text-xs leading-5 opacity-80 border-default font-medium",
{
variants: {
status: {
@@ -43,14 +44,46 @@ export function Event({
eventDuration,
disabled,
onEventClick,
isHovered = false,
}: EventProps) {
const selected = currentlySelectedEventId === event.id;
const { options } = event;
const Component = onEventClick ? "button" : "div";
const dayOfWeek = dayjs(event.start).day();
const tooltipSide = dayOfWeek >= 1 && dayOfWeek <= 4 ? "right" : "left";
const tooltipContent = (
<div className="flex min-w-[200px] max-w-[300px] flex-col gap-1 py-1">
<div className="flex items-start gap-2">
{options?.color && (
<div className="mt-1 h-3 w-1 shrink-0 rounded-sm" style={{ backgroundColor: options.color }}></div>
)}
<div className="flex-1">
<div className="font-semibold leading-tight">{event.title}</div>
{!event.options?.hideTime && (
<div className="text-inverted-muted mt-1 text-xs">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</div>
)}
{event.description && (
<div className="text-inverted-muted mt-1 text-xs leading-snug">{event.description}</div>
)}
{options?.status && options.status !== "ACCEPTED" && (
<div className="text-inverted-muted mt-1 text-xs capitalize">
{options.status.toLowerCase().replace("_", " ")}
</div>
)}
</div>
</div>
</div>
);
const displayType = eventDuration < 40 ? "single-line" : eventDuration < 45 ? "multi-line" : "full";
return (
<Tooltip content={event.title}>
<Tooltip content={tooltipContent} className="max-w-none" side={tooltipSide}>
<Component
onClick={() => onEventClick?.(event)} // Note this is not the button event. It is the calendar event.
className={classNames(
@@ -59,32 +92,41 @@ export function Event({
disabled,
selected,
}),
options?.className
)}>
options?.className,
isHovered && "ring-brand-default shadow-lg ring-2 ring-offset-0"
)}
style={{
transition: "all 100ms ease-out",
}}>
{options?.color && (
<div
className="-ml-1.5 mr-1.5 h-full w-[3px] shrink-0"
style={{ backgroundColor: options.color }}></div>
)}
<div className={classNames("flex", eventDuration > 30 && "flex-col py-1")}>
<div
className={classNames(
"flex w-full gap-2 overflow-hidden overflow-ellipsis whitespace-nowrap text-left leading-4",
eventDuration <= 30 && "items-center"
)}>
<span>{event.title}</span>
{eventDuration <= 30 && !event.options?.hideTime && (
<p className="text-subtle mt-1 w-full whitespace-nowrap text-left text-[10px] leading-none">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</p>
)}
</div>
{eventDuration > 30 && !event.options?.hideTime && (
<p className="text-subtle mt-1 text-left text-[10px] leading-none">
<div className={classNames("flex w-full", displayType !== "single-line" && "flex-col py-1")}>
{displayType === "single-line" && (
<div
className={classNames(
"flex w-full shrink-0 gap-2 overflow-hidden overflow-ellipsis whitespace-nowrap text-left leading-4",
"items-center"
)}>
<span>{event.title}</span>
{!event.options?.hideTime && (
<p className="text-subtle mt-1 w-full whitespace-nowrap text-left text-[10px] leading-none">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</p>
)}
</div>
)}
{displayType !== "single-line" && (
<p className={classNames("shrink-0 whitespace-nowrap text-left leading-4")}>{event.title}</p>
)}
{displayType !== "single-line" && !event.options?.hideTime && (
<p className="text-subtle mt-1 whitespace-nowrap text-left text-[10px] leading-none">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</p>
)}
{eventDuration > 45 && event.description && (
{displayType === "full" && event.description && (
<p className="text-subtle mt-1 text-left text-[10px] leading-none">{event.description}</p>
)}
</div>
@@ -1,9 +1,10 @@
import { useRef } from "react";
import { useMemo, useState } from "react";
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
import { useCalendarStore } from "../../state/store";
import { calculateEventLayouts, createLayoutMap } from "../../utils/overlap";
import { Event } from "./Event";
type Props = {
@@ -20,110 +21,84 @@ export function EventList({ day }: Props) {
shallow
);
// Use a ref so we dont trigger a re-render
const longestRef = useRef<{
start: Date;
end: Date;
duration: number;
idx: number;
} | null>(null);
const [hoveredEventId, setHoveredEventId] = useState<number | null>(null);
const dayEvents = useMemo(() => {
return events.filter((event) => {
return dayjs(event.start).isSame(day, "day") && !event.options?.allDay;
});
}, [events, day]);
const layoutMap = useMemo(() => {
const layouts = calculateEventLayouts(dayEvents);
return createLayoutMap(layouts);
}, [dayEvents]);
const eventCalculations = useMemo(() => {
return new Map(
dayEvents.map((event) => {
const eventStart = dayjs(event.start);
const eventEnd = dayjs(event.end);
const eventDuration = eventEnd.diff(eventStart, "minutes");
const eventStartHour = eventStart.hour();
const eventStartDiff = (eventStartHour - (startHour || 0)) * 60 + eventStart.minute();
return [
event.id,
{
eventStart,
eventDuration,
eventStartDiff,
},
];
})
);
}, [dayEvents, startHour]);
// Find which overlap group the hovered event belongs to
const hoveredEventLayout = hoveredEventId ? layoutMap.get(hoveredEventId) : null;
const hoveredGroupIndex = hoveredEventLayout?.groupIndex ?? null;
return (
<>
{events
.filter((event) => {
return dayjs(event.start).isSame(day, "day") && !event.options?.allDay; // Filter all events that are not allDay and that are on the current day
})
.map((event, idx, eventsArray) => {
let width = 90;
let marginLeft: string | number = 0;
let right = 0;
let zIndex = 61;
{dayEvents.map((event) => {
const layout = layoutMap.get(event.id);
if (!layout) return null;
const eventStart = dayjs(event.start);
const eventEnd = dayjs(event.end);
const calc = eventCalculations.get(event.id);
if (!calc) return null;
const eventDuration = eventEnd.diff(eventStart, "minutes");
const { eventStart, eventDuration, eventStartDiff } = calc;
const eventStartHour = eventStart.hour();
const eventStartDiff = (eventStartHour - (startHour || 0)) * 60 + eventStart.minute();
const nextEvent = eventsArray[idx + 1];
const prevEvent = eventsArray[idx - 1];
const isHovered = hoveredEventId === event.id;
const isInHoveredGroup = hoveredGroupIndex !== null && layout.groupIndex === hoveredGroupIndex;
const zIndex = isHovered ? 100 : layout.baseZIndex;
if (!longestRef.current) {
longestRef.current = {
idx,
start: eventStart.toDate(),
end: eventEnd.toDate(),
duration: eventDuration,
};
} else if (
eventDuration > longestRef.current.duration &&
eventStart.isBetween(longestRef.current.start, longestRef.current.end)
) {
longestRef.current = {
idx,
start: eventStart.toDate(),
end: eventEnd.toDate(),
duration: eventDuration,
};
}
// By default longest event doesnt have any styles applied
if (longestRef.current.idx !== idx) {
if (nextEvent) {
// If we have a next event
const nextStart = dayjs(nextEvent.start);
// If the next event is inbetween the longest start and end make 65% width
if (nextStart.isBetween(longestRef.current.start, longestRef.current.end)) {
zIndex = 65;
marginLeft = "auto";
right = 4;
width = width / 2;
// If not - we check to see if the next starts within 5 mins of this event - allowing us to do side by side events whenwe have
// close start times
} else if (nextStart.isBetween(eventStart.add(-5, "minutes"), eventStart.add(5, "minutes"))) {
zIndex = 65;
marginLeft = "auto";
right = 4;
width = width / 2;
}
} else if (prevEvent) {
const prevStart = dayjs(prevEvent.start);
// If the next event is inbetween the longest start and end make 65% width
if (prevStart.isBetween(longestRef.current.start, longestRef.current.end)) {
zIndex = 65;
marginLeft = "auto";
right = 4;
// If not - we check to see if the next starts within 5 mins of this event - allowing us to do side by side events whenwe have
// close start times (Inverse of above )
} else if (eventStart.isBetween(prevStart.add(5, "minutes"), prevStart.add(-5, "minutes"))) {
zIndex = 65;
right = 4;
width = width / 2;
}
}
}
return (
<div
key={`${event.id}-${eventStart.toISOString()}`}
className="absolute inset-x-1 "
data-testid={event.options?.["data-test-id"]}
style={{
marginLeft,
zIndex,
right: `calc(${right}% - 1px)`,
width: `${width}%`,
top: `calc(${eventStartDiff}*var(--one-minute-height))`,
height: `calc(${eventDuration}*var(--one-minute-height))`,
}}>
<Event event={event} eventDuration={eventDuration} onEventClick={eventOnClick} />
</div>
);
})}
return (
<div
key={`${event.id}-${eventStart.toISOString()}`}
className="absolute transition-all duration-100 ease-out"
data-testid={event.options?.["data-test-id"]}
onMouseEnter={() => setHoveredEventId(event.id)}
onMouseLeave={() => setHoveredEventId(null)}
style={{
left: `${layout.leftOffsetPercent}%`,
width: `${layout.widthPercent}%`,
zIndex,
top: `calc(${eventStartDiff}*var(--one-minute-height))`,
height: `max(15px, calc(${eventDuration}*var(--one-minute-height)))`,
transform: isHovered ? "scale(1.02)" : "scale(1)",
opacity: hoveredGroupIndex !== null && !isHovered && isInHoveredGroup ? 0.6 : 1,
}}>
<Event
event={event}
eventDuration={eventDuration}
onEventClick={eventOnClick}
isHovered={isHovered}
/>
</div>
);
})}
</>
);
}
@@ -1,4 +1,6 @@
import { create } from "zustand";
import React from "react";
import { createStore, useStore } from "zustand";
import type { StoreApi } from "zustand";
import dayjs from "@calcom/dayjs";
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
@@ -25,60 +27,77 @@ const defaultState: CalendarComponentProps = {
borderColor: "default",
};
export const useCalendarStore = create<CalendarStoreProps>((set) => ({
...defaultState,
setView: (view: CalendarComponentProps["view"]) => set({ view }),
setStartDate: (startDate: CalendarComponentProps["startDate"]) => set({ startDate }),
setEndDate: (endDate: CalendarComponentProps["endDate"]) => set({ endDate }),
setEvents: (events: CalendarComponentProps["events"]) => set({ events }),
// This looks a bit odd but init state only overrides the public props + actions as we don't want to override our internal state
initState: (state: CalendarState & CalendarPublicActions) => {
// Handle sorting of events if required
let events = state.events;
export function createCalendarStore(initial?: Partial<CalendarComponentProps>): StoreApi<CalendarStoreProps> {
return createStore<CalendarStoreProps>((set) => ({
...defaultState,
...initial,
setView: (view: CalendarComponentProps["view"]) => set({ view }),
setStartDate: (startDate: CalendarComponentProps["startDate"]) => set({ startDate }),
setEndDate: (endDate: CalendarComponentProps["endDate"]) => set({ endDate }),
setEvents: (events: CalendarComponentProps["events"]) => set({ events }),
// This looks a bit odd but init state only overrides the public props + actions as we don't want to override our internal state
initState: (state: CalendarState & CalendarPublicActions) => {
// Handle sorting of events if required
let events = state.events;
if (state.sortEvents) {
events = state.events.sort((a, b) => dayjs(a.start).valueOf() - dayjs(b.start).valueOf());
}
const blockingDates = mergeOverlappingDateRanges(state.blockingDates || []); // We merge overlapping dates so we don't get duplicate blocking "Cells" in the UI
if (state.sortEvents) {
events = [...state.events].sort((a, b) => dayjs(a.start).valueOf() - dayjs(b.start).valueOf());
}
const blockingDates = mergeOverlappingDateRanges(state.blockingDates || []); // We merge overlapping dates so we don't get duplicate blocking "Cells" in the UI
set({
...state,
blockingDates,
events,
});
},
setSelectedEvent: (event) => set({ selectedEvent: event }),
handleDateChange: (payload) =>
set((state) => {
const { startDate, endDate } = state;
if (payload === "INCREMENT") {
const newStartDate = dayjs(startDate).add(1, state.view).toDate();
const newEndDate = dayjs(endDate).add(1, state.view).toDate();
set({
...state,
blockingDates,
events,
});
},
setSelectedEvent: (event) => set({ selectedEvent: event }),
handleDateChange: (payload) =>
set((state) => {
const { startDate, endDate } = state;
if (payload === "INCREMENT") {
const newStartDate = dayjs(startDate).add(1, state.view).toDate();
const newEndDate = dayjs(endDate).add(1, state.view).toDate();
// Do nothing if
if (
(state.minDate && newStartDate < state.minDate) ||
(state.maxDate && newEndDate > state.maxDate)
) {
// Do nothing if
if (
(state.minDate && newStartDate < state.minDate) ||
(state.maxDate && newEndDate > state.maxDate)
) {
return {
startDate,
endDate,
};
}
// We call this callback if we have it -> Allows you to change your state outside of the component
state.onDateChange?.(newStartDate, newEndDate);
return {
startDate,
endDate,
startDate: newStartDate,
endDate: newEndDate,
};
}
// We call this callback if we have it -> Allows you to change your state outside of the component
const newStartDate = dayjs(startDate).subtract(1, state.view).toDate();
const newEndDate = dayjs(endDate).subtract(1, state.view).toDate();
state.onDateChange?.(newStartDate, newEndDate);
return {
startDate: newStartDate,
endDate: newEndDate,
};
}
const newStartDate = dayjs(startDate).subtract(1, state.view).toDate();
const newEndDate = dayjs(endDate).subtract(1, state.view).toDate();
state.onDateChange?.(newStartDate, newEndDate);
return {
startDate: newStartDate,
endDate: newEndDate,
};
}),
}));
}),
}));
}
export const CalendarStoreContext = React.createContext<StoreApi<CalendarStoreProps> | null>(null);
export function useCalendarStore<T>(
selector: (state: CalendarStoreProps) => T,
equalityFn?: (a: T, b: T) => boolean
): T {
const store = React.useContext(CalendarStoreContext);
if (!store) {
throw new Error("useCalendarStore must be used within a CalendarStoreProvider");
}
return useStore(store, selector, equalityFn);
}
@@ -0,0 +1,364 @@
import { describe, expect, it } from "vitest";
import type { CalendarEvent } from "../types/events";
import {
buildOverlapGroups,
calculateEventLayouts,
createLayoutMap,
sortEvents,
} from "./overlap";
describe("overlap utility", () => {
describe("sortEvents", () => {
it("should sort events by start time ascending", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T09:00:00"), end: new Date("2024-01-01T10:00:00") },
{ id: 3, title: "Event 3", start: new Date("2024-01-01T11:00:00"), end: new Date("2024-01-01T12:00:00") },
];
const sorted = sortEvents(events);
expect(sorted[0].id).toBe(2);
expect(sorted[1].id).toBe(1);
expect(sorted[2].id).toBe(3);
});
it("should sort events with same start time by end time descending (longer first)", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T12:00:00") },
{ id: 3, title: "Event 3", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T10:30:00") },
];
const sorted = sortEvents(events);
expect(sorted[0].id).toBe(2); // Longest (2 hours)
expect(sorted[1].id).toBe(1); // Medium (1 hour)
expect(sorted[2].id).toBe(3); // Shortest (30 min)
});
it("should not mutate the original array", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T09:00:00"), end: new Date("2024-01-01T10:00:00") },
];
const originalFirstId = events[0].id;
sortEvents(events);
expect(events[0].id).toBe(originalFirstId);
});
});
describe("buildOverlapGroups", () => {
it("should return empty array for no events", () => {
const groups = buildOverlapGroups([]);
expect(groups).toEqual([]);
});
it("should return single group for single event", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
];
const groups = buildOverlapGroups(events);
expect(groups).toHaveLength(1);
expect(groups[0]).toHaveLength(1);
expect(groups[0][0].id).toBe(1);
});
it("should group two overlapping events together", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T10:30:00"), end: new Date("2024-01-01T11:30:00") },
];
const sorted = sortEvents(events);
const groups = buildOverlapGroups(sorted);
expect(groups).toHaveLength(1);
expect(groups[0]).toHaveLength(2);
expect(groups[0][0].id).toBe(1);
expect(groups[0][1].id).toBe(2);
});
it("should separate non-overlapping events into different groups", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T11:00:00"), end: new Date("2024-01-01T12:00:00") },
];
const sorted = sortEvents(events);
const groups = buildOverlapGroups(sorted);
expect(groups).toHaveLength(2);
expect(groups[0]).toHaveLength(1);
expect(groups[1]).toHaveLength(1);
expect(groups[0][0].id).toBe(1);
expect(groups[1][0].id).toBe(2);
});
it("should handle chain overlaps (A overlaps B, B overlaps C)", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T10:30:00"), end: new Date("2024-01-01T11:30:00") },
{ id: 3, title: "Event 3", start: new Date("2024-01-01T11:00:00"), end: new Date("2024-01-01T12:00:00") },
];
const sorted = sortEvents(events);
const groups = buildOverlapGroups(sorted);
expect(groups).toHaveLength(1);
expect(groups[0]).toHaveLength(3);
expect(groups[0][0].id).toBe(1);
expect(groups[0][1].id).toBe(2);
expect(groups[0][2].id).toBe(3);
});
it("should handle multiple separate overlap groups", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T09:00:00"), end: new Date("2024-01-01T10:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T09:30:00"), end: new Date("2024-01-01T10:30:00") },
{ id: 3, title: "Event 3", start: new Date("2024-01-01T11:00:00"), end: new Date("2024-01-01T12:00:00") },
{ id: 4, title: "Event 4", start: new Date("2024-01-01T11:30:00"), end: new Date("2024-01-01T12:30:00") },
];
const sorted = sortEvents(events);
const groups = buildOverlapGroups(sorted);
expect(groups).toHaveLength(2);
expect(groups[0]).toHaveLength(2);
expect(groups[1]).toHaveLength(2);
expect(groups[0][0].id).toBe(1);
expect(groups[0][1].id).toBe(2);
expect(groups[1][0].id).toBe(3);
expect(groups[1][1].id).toBe(4);
});
it("should handle events that start at the same time", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T12:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 3, title: "Event 3", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T10:30:00") },
];
const sorted = sortEvents(events);
const groups = buildOverlapGroups(sorted);
expect(groups).toHaveLength(1);
expect(groups[0]).toHaveLength(3);
});
});
describe("calculateEventLayouts", () => {
it("should calculate layout for single event", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
];
const layouts = calculateEventLayouts(events);
expect(layouts).toHaveLength(1);
expect(layouts[0].event.id).toBe(1);
expect(layouts[0].leftOffsetPercent).toBe(0);
expect(layouts[0].widthPercent).toBe(80);
expect(layouts[0].baseZIndex).toBe(60);
expect(layouts[0].groupIndex).toBe(0);
expect(layouts[0].indexInGroup).toBe(0);
});
it("should calculate cascading layout for two overlapping events", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T10:30:00"), end: new Date("2024-01-01T11:30:00") },
];
const layouts = calculateEventLayouts(events);
expect(layouts).toHaveLength(2);
expect(layouts[0].event.id).toBe(1);
expect(layouts[0].leftOffsetPercent).toBe(0);
expect(layouts[0].widthPercent).toBe(80);
expect(layouts[0].baseZIndex).toBe(60);
expect(layouts[1].event.id).toBe(2);
expect(layouts[1].leftOffsetPercent).toBe(8);
expect(layouts[1].widthPercent).toBe(80);
expect(layouts[1].baseZIndex).toBe(61);
});
it("should calculate cascading layout for three overlapping events", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T10:30:00"), end: new Date("2024-01-01T11:30:00") },
{ id: 3, title: "Event 3", start: new Date("2024-01-01T11:00:00"), end: new Date("2024-01-01T12:00:00") },
];
const layouts = calculateEventLayouts(events);
expect(layouts).toHaveLength(3);
expect(layouts[0].leftOffsetPercent).toBe(0);
expect(layouts[1].leftOffsetPercent).toBe(8);
expect(layouts[2].leftOffsetPercent).toBe(16);
expect(layouts[0].baseZIndex).toBe(60);
expect(layouts[1].baseZIndex).toBe(61);
expect(layouts[2].baseZIndex).toBe(62);
});
it("should respect custom configuration", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T10:30:00"), end: new Date("2024-01-01T11:30:00") },
];
const layouts = calculateEventLayouts(events, {
baseWidthPercent: 70,
offsetStepPercent: 10,
baseZIndex: 50,
});
expect(layouts[0].widthPercent).toBe(70);
expect(layouts[0].leftOffsetPercent).toBe(0);
expect(layouts[0].baseZIndex).toBe(50);
expect(layouts[1].widthPercent).toBe(70);
expect(layouts[1].leftOffsetPercent).toBe(10);
expect(layouts[1].baseZIndex).toBe(51);
});
it("should handle non-overlapping events in separate groups", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T11:00:00"), end: new Date("2024-01-01T12:00:00") },
];
const layouts = calculateEventLayouts(events);
expect(layouts).toHaveLength(2);
expect(layouts[0].groupIndex).toBe(0);
expect(layouts[0].indexInGroup).toBe(0);
expect(layouts[0].leftOffsetPercent).toBe(0);
expect(layouts[1].groupIndex).toBe(1);
expect(layouts[1].indexInGroup).toBe(0);
expect(layouts[1].leftOffsetPercent).toBe(0);
});
it("should prevent overflow with many overlapping events (dense scenario)", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T09:00:00"), end: new Date("2024-01-01T09:30:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T09:15:00"), end: new Date("2024-01-01T10:00:00") },
{ id: 3, title: "Event 3", start: new Date("2024-01-01T09:45:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 4, title: "Event 4", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T10:30:00") },
{ id: 5, title: "Event 5", start: new Date("2024-01-01T10:15:00"), end: new Date("2024-01-01T11:30:00") },
{ id: 6, title: "Event 6", start: new Date("2024-01-01T11:00:00"), end: new Date("2024-01-01T12:00:00") },
{ id: 7, title: "Event 7", start: new Date("2024-01-01T11:30:00"), end: new Date("2024-01-01T12:30:00") },
{ id: 8, title: "Event 8", start: new Date("2024-01-01T12:00:00"), end: new Date("2024-01-01T13:30:00") },
{ id: 9, title: "Event 9", start: new Date("2024-01-01T12:30:00"), end: new Date("2024-01-01T13:00:00") },
{ id: 10, title: "Event 10", start: new Date("2024-01-01T13:00:00"), end: new Date("2024-01-01T14:00:00") },
{ id: 11, title: "Event 11", start: new Date("2024-01-01T13:15:00"), end: new Date("2024-01-01T14:00:00") },
];
const layouts = calculateEventLayouts(events);
layouts.forEach((layout) => {
const totalWidth = layout.leftOffsetPercent + layout.widthPercent;
expect(totalWidth).toBeLessThanOrEqual(100 - 0.5);
});
expect(layouts).toHaveLength(11);
});
it("should respect safety margin with 20+ overlapping events", () => {
const events: CalendarEvent[] = Array.from({ length: 21 }, (_, i) => ({
id: i + 1,
title: `Event ${i + 1}`,
start: new Date(`2024-01-01T09:${String(i * 2).padStart(2, '0')}:00`),
end: new Date(`2024-01-01T10:${String(i * 2).padStart(2, '0')}:00`),
}));
const layouts = calculateEventLayouts(events);
expect(layouts).toHaveLength(21);
layouts.forEach((layout) => {
const totalWidth = layout.leftOffsetPercent + layout.widthPercent;
expect(totalWidth).toBeLessThanOrEqual(100 - 0.5);
});
const lastLayout = layouts[layouts.length - 1];
expect(lastLayout.leftOffsetPercent + lastLayout.widthPercent).toBeLessThanOrEqual(99.5);
});
it("should compress offset step for dense overlaps while maintaining cascade", () => {
const events: CalendarEvent[] = Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
title: `Event ${i + 1}`,
start: new Date(`2024-01-01T10:${String(i * 5).padStart(2, '0')}:00`),
end: new Date(`2024-01-01T11:${String(i * 5).padStart(2, '0')}:00`),
}));
const layouts = calculateEventLayouts(events);
expect(layouts).toHaveLength(12);
layouts.forEach((layout, index) => {
expect(layout.leftOffsetPercent + layout.widthPercent).toBeLessThanOrEqual(100);
if (index > 0) {
expect(layout.leftOffsetPercent).toBeGreaterThan(layouts[index - 1].leftOffsetPercent);
}
});
const lastLayout = layouts[layouts.length - 1];
expect(lastLayout.leftOffsetPercent + lastLayout.widthPercent).toBeLessThanOrEqual(100);
});
it("should maintain full width for small overlap groups", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T10:30:00"), end: new Date("2024-01-01T11:30:00") },
{ id: 3, title: "Event 3", start: new Date("2024-01-01T11:00:00"), end: new Date("2024-01-01T12:00:00") },
];
const layouts = calculateEventLayouts(events);
expect(layouts[0].widthPercent).toBe(80);
expect(layouts[1].widthPercent).toBe(80);
expect(layouts[2].widthPercent).toBe(80);
expect(layouts[0].leftOffsetPercent).toBe(0);
expect(layouts[1].leftOffsetPercent).toBe(8);
expect(layouts[2].leftOffsetPercent).toBe(16);
});
});
describe("createLayoutMap", () => {
it("should create a map from event ID to layout", () => {
const events: CalendarEvent[] = [
{ id: 1, title: "Event 1", start: new Date("2024-01-01T10:00:00"), end: new Date("2024-01-01T11:00:00") },
{ id: 2, title: "Event 2", start: new Date("2024-01-01T10:30:00"), end: new Date("2024-01-01T11:30:00") },
];
const layouts = calculateEventLayouts(events);
const map = createLayoutMap(layouts);
expect(map.size).toBe(2);
expect(map.get(1)?.event.id).toBe(1);
expect(map.get(2)?.event.id).toBe(2);
expect(map.get(1)?.leftOffsetPercent).toBe(0);
expect(map.get(2)?.leftOffsetPercent).toBe(8);
});
it("should handle empty layouts", () => {
const map = createLayoutMap([]);
expect(map.size).toBe(0);
});
});
});
@@ -0,0 +1,155 @@
import dayjs from "@calcom/dayjs";
import type { CalendarEvent } from "../types/events";
export interface OverlapLayoutConfig {
baseWidthPercent?: number;
offsetStepPercent?: number;
baseZIndex?: number;
safetyMarginPercent?: number;
}
export interface EventLayout {
event: CalendarEvent;
leftOffsetPercent: number;
widthPercent: number;
baseZIndex: number;
groupIndex: number;
indexInGroup: number;
}
const DEFAULT_CONFIG: Required<OverlapLayoutConfig> = {
baseWidthPercent: 80,
offsetStepPercent: 8,
baseZIndex: 60,
safetyMarginPercent: 0.5,
};
/**
* Rounds a number to 3 decimal places using standard rounding
*/
function round3(value: number): number {
return Number(value.toFixed(3));
}
/**
* Floors a number to 3 decimal places (always rounds down)
*/
function floor3(value: number): number {
return Math.floor(value * 1000) / 1000;
}
/**
* Sorts events by start time (ascending), then by end time (descending for longer events first)
*/
export function sortEvents(events: CalendarEvent[]): CalendarEvent[] {
return [...events].sort((a, b) => {
const startA = dayjs(a.start);
const startB = dayjs(b.start);
const startDiff = startA.diff(startB);
if (startDiff !== 0) {
return startDiff;
}
const endA = dayjs(a.end);
const endB = dayjs(b.end);
return endB.diff(endA);
});
}
/**
* Groups overlapping events together using a sweep algorithm
* Events overlap if one starts before another ends
*/
export function buildOverlapGroups(sortedEvents: CalendarEvent[]): CalendarEvent[][] {
if (sortedEvents.length === 0) {
return [];
}
const groups: CalendarEvent[][] = [];
let currentGroup: CalendarEvent[] = [sortedEvents[0]];
let currentGroupEnd = dayjs(sortedEvents[0].end);
for (let i = 1; i < sortedEvents.length; i++) {
const event = sortedEvents[i];
const eventStart = dayjs(event.start);
const eventEnd = dayjs(event.end);
if (eventStart.isBefore(currentGroupEnd)) {
currentGroup.push(event);
if (eventEnd.isAfter(currentGroupEnd)) {
currentGroupEnd = eventEnd;
}
} else {
groups.push(currentGroup);
currentGroup = [event];
currentGroupEnd = eventEnd;
}
}
groups.push(currentGroup);
return groups;
}
/**
* Calculates layout information for all events including position and z-index
* Dynamically adjusts offset step to prevent overflow when many events overlap
* Uses safety margin and floor rounding to guarantee no overflow even with CSS box model effects
*/
export function calculateEventLayouts(
events: CalendarEvent[],
config: OverlapLayoutConfig = {}
): EventLayout[] {
const { baseWidthPercent, offsetStepPercent, baseZIndex, safetyMarginPercent } = {
...DEFAULT_CONFIG,
...config,
};
const sortedEvents = sortEvents(events);
const groups = buildOverlapGroups(sortedEvents);
const layouts: EventLayout[] = [];
groups.forEach((group, groupIndex) => {
const groupSize = group.length;
const allowedOffsetSpace = Math.max(0, 100 - baseWidthPercent - safetyMarginPercent);
const stepUsed = Math.min(
offsetStepPercent,
allowedOffsetSpace / Math.max(1, groupSize - 1)
);
group.forEach((event, indexInGroup) => {
const leftRaw = indexInGroup * stepUsed;
const left = round3(leftRaw);
const maxWidthCap = 100 - left - safetyMarginPercent;
const widthCap = Math.min(baseWidthPercent, maxWidthCap);
const width = floor3(Math.max(0, widthCap));
layouts.push({
event,
leftOffsetPercent: left,
widthPercent: width,
baseZIndex: baseZIndex + indexInGroup,
groupIndex,
indexInGroup,
});
});
});
return layouts;
}
/**
* Creates a map from event ID to layout information for quick lookup
*/
export function createLayoutMap(layouts: EventLayout[]): Map<number, EventLayout> {
const map = new Map<number, EventLayout>();
layouts.forEach((layout) => {
map.set(layout.event.id, layout);
});
return map;
}
@@ -29,6 +29,7 @@ export function Tooltip({
className={classNames(
"calcom-tooltip",
side === "top" && "-mt-7",
side === "left" && "mr-2",
side === "right" && "ml-2",
"bg-inverted text-inverted relative z-50 rounded-md px-2 py-1 text-xs font-semibold shadow-lg",
props.className && `${props.className}`