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:
Eunjae Lee
2025-11-05 15:56:24 +00:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Sean Brydon
parent c48f2043f2
commit b01cb27c56
9 changed files with 1270 additions and 168 deletions
@@ -9,6 +9,10 @@ const LINKS = [
title: "Bookings by Hour",
href: "/settings/admin/playground/bookings-by-hour",
},
{
title: "Weekly Calendar",
href: "/settings/admin/playground/weekly-calendar",
},
];
export default function Page() {
@@ -0,0 +1,521 @@
"use client";
import { useState } from "react";
import dayjs from "@calcom/dayjs";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import type { CalendarComponentProps } from "@calcom/features/calendars/weeklyview/types/state";
const makeDate = (dayOffset: number, hour: number, minute: number = 0) => {
return dayjs("2025-01-06").add(dayOffset, "day").hour(hour).minute(minute).second(0).toDate();
};
const getBaseProps = (events: CalendarEvent[]): CalendarComponentProps => ({
startDate: dayjs("2025-01-06").toDate(), // Monday
endDate: dayjs("2025-01-12").toDate(), // Sunday
events,
startHour: 6,
endHour: 18,
gridCellsPerHour: 4,
timezone: "UTC",
showBackgroundPattern: false,
showBorder: false,
hideHeader: true,
borderColor: "subtle",
});
type Scenario = {
id: string;
title: string;
description: string;
expected: string;
events: CalendarEvent[];
};
const scenarios: Scenario[] = [
{
id: "two-overlapping",
title: "Two Overlapping Events",
description: "Two events with overlapping time ranges on the same day",
expected:
"Second event should be offset 8% to the right, both 80% width. Hover should bring event to front.",
events: [
{
id: 1,
title: "Meeting A",
start: makeDate(0, 10, 0),
end: makeDate(0, 11, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 2,
title: "Meeting B",
start: makeDate(0, 10, 30),
end: makeDate(0, 11, 30),
options: { status: "ACCEPTED", color: "#10b981" },
},
],
},
{
id: "three-cascading",
title: "Three Overlapping Events (Cascading)",
description: "Three events that overlap, creating a cascading effect",
expected:
"Events should cascade with offsets 0%, 8%, 16%. Z-index should increment. Hover brings any to top.",
events: [
{
id: 3,
title: "Long Meeting",
start: makeDate(1, 10, 0),
end: makeDate(1, 12, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 4,
title: "Mid Meeting",
start: makeDate(1, 10, 30),
end: makeDate(1, 11, 30),
options: { status: "PENDING", color: "#f59e0b" },
},
{
id: 5,
title: "Late Meeting",
start: makeDate(1, 11, 0),
end: makeDate(1, 12, 30),
options: { status: "ACCEPTED", color: "#10b981" },
},
],
},
{
id: "non-overlapping",
title: "Non-Overlapping Events",
description: "Events that don't overlap should not cascade",
expected: "Both events at 0% offset (separate groups), no cascade. Both should be 80% width.",
events: [
{
id: 6,
title: "Morning Meeting",
start: makeDate(2, 10, 0),
end: makeDate(2, 11, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 7,
title: "Afternoon Meeting",
start: makeDate(2, 12, 0),
end: makeDate(2, 13, 0),
options: { status: "ACCEPTED", color: "#10b981" },
},
],
},
{
id: "same-start-time",
title: "Same Start Time, Different Durations",
description: "Multiple events starting at the same time with varying lengths",
expected: "Longest event first (base of cascade), shorter ones offset 8%, 16%. All start at 10:00.",
events: [
{
id: 8,
title: "2-Hour Meeting",
start: makeDate(3, 10, 0),
end: makeDate(3, 12, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 9,
title: "1.5-Hour Meeting",
start: makeDate(3, 10, 0),
end: makeDate(3, 11, 30),
options: { status: "ACCEPTED", color: "#f59e0b" },
},
{
id: 10,
title: "30-Min Meeting",
start: makeDate(3, 10, 0),
end: makeDate(3, 10, 30),
options: { status: "PENDING", color: "#10b981" },
},
],
},
{
id: "chain-overlaps",
title: "Chain Overlaps (A→B→C)",
description: "Events where A overlaps B, and B overlaps C",
expected: "Single overlap group with cascading offsets 0%, 8%, 16%.",
events: [
{
id: 11,
title: "Event A",
start: makeDate(4, 10, 0),
end: makeDate(4, 11, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 12,
title: "Event B",
start: makeDate(4, 10, 30),
end: makeDate(4, 11, 30),
options: { status: "ACCEPTED", color: "#f59e0b" },
},
{
id: 13,
title: "Event C",
start: makeDate(4, 11, 0),
end: makeDate(4, 12, 0),
options: { status: "ACCEPTED", color: "#10b981" },
},
],
},
{
id: "dense-day",
title: "Dense Day (20+ Events)",
description: "A very busy day with many overlapping events",
expected:
"Visually tight stack with multiple cascading levels. Right edge should not overflow. Hover should still work.",
events: [
{
id: 14,
title: "Team Standup",
start: makeDate(5, 9, 0),
end: makeDate(5, 9, 30),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 15,
title: "Client Call",
start: makeDate(5, 9, 15),
end: makeDate(5, 10, 0),
options: { status: "ACCEPTED", color: "#10b981" },
},
{
id: 16,
title: "Design Review",
start: makeDate(5, 9, 45),
end: makeDate(5, 11, 0),
options: { status: "PENDING", color: "#f59e0b" },
},
{
id: 17,
title: "1-on-1",
start: makeDate(5, 10, 0),
end: makeDate(5, 10, 30),
options: { status: "ACCEPTED", color: "#8b5cf6" },
},
{
id: 18,
title: "Planning",
start: makeDate(5, 10, 15),
end: makeDate(5, 11, 30),
options: { status: "ACCEPTED", color: "#ec4899" },
},
{
id: 19,
title: "Code Review",
start: makeDate(5, 11, 0),
end: makeDate(5, 12, 0),
options: { status: "ACCEPTED", color: "#14b8a6" },
},
{
id: 20,
title: "Lunch Meeting",
start: makeDate(5, 11, 30),
end: makeDate(5, 12, 30),
options: { status: "ACCEPTED", color: "#f97316" },
},
{
id: 21,
title: "Workshop",
start: makeDate(5, 12, 0),
end: makeDate(5, 13, 30),
options: { status: "ACCEPTED", color: "#06b6d4" },
},
{
id: 22,
title: "Interview",
start: makeDate(5, 12, 30),
end: makeDate(5, 13, 0),
options: { status: "PENDING", color: "#84cc16" },
},
{
id: 23,
title: "Retrospective",
start: makeDate(5, 13, 0),
end: makeDate(5, 14, 0),
options: { status: "ACCEPTED", color: "#a855f7" },
},
{
id: 24,
title: "Demo",
start: makeDate(5, 13, 15),
end: makeDate(5, 14, 0),
options: { status: "CANCELLED", color: "#ef4444" },
},
{
id: 30,
title: "Quick Sync",
start: makeDate(5, 9, 10),
end: makeDate(5, 9, 40),
options: { status: "ACCEPTED", color: "#6366f1" },
},
{
id: 31,
title: "Architecture Discussion",
start: makeDate(5, 9, 30),
end: makeDate(5, 10, 30),
options: { status: "ACCEPTED", color: "#ec4899" },
},
{
id: 32,
title: "Sprint Planning",
start: makeDate(5, 10, 10),
end: makeDate(5, 11, 0),
options: { status: "ACCEPTED", color: "#f59e0b" },
},
{
id: 33,
title: "Product Demo",
start: makeDate(5, 10, 45),
end: makeDate(5, 11, 45),
options: { status: "PENDING", color: "#14b8a6" },
},
{
id: 34,
title: "Bug Triage",
start: makeDate(5, 11, 15),
end: makeDate(5, 12, 15),
options: { status: "ACCEPTED", color: "#8b5cf6" },
},
{
id: 35,
title: "Customer Feedback",
start: makeDate(5, 11, 45),
end: makeDate(5, 12, 45),
options: { status: "ACCEPTED", color: "#10b981" },
},
{
id: 36,
title: "Team Sync",
start: makeDate(5, 12, 15),
end: makeDate(5, 13, 15),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 37,
title: "Tech Talk",
start: makeDate(5, 12, 45),
end: makeDate(5, 13, 45),
options: { status: "ACCEPTED", color: "#f97316" },
},
{
id: 38,
title: "Standup Follow-up",
start: makeDate(5, 13, 30),
end: makeDate(5, 14, 0),
options: { status: "ACCEPTED", color: "#06b6d4" },
},
{
id: 39,
title: "Office Hours",
start: makeDate(5, 13, 45),
end: makeDate(5, 14, 30),
options: { status: "ACCEPTED", color: "#a855f7" },
},
],
},
{
id: "touching-events",
title: "Touching Events (Edge Case)",
description: "Events that touch exactly at boundaries",
expected: "Separate groups; no cascade; both at 0% offset. Events touching at 11:00 should not overlap.",
events: [
{
id: 25,
title: "Morning Block",
start: makeDate(6, 10, 0),
end: makeDate(6, 11, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 26,
title: "Afternoon Block",
start: makeDate(6, 11, 0),
end: makeDate(6, 12, 0),
options: { status: "ACCEPTED", color: "#10b981" },
},
],
},
{
id: "mixed-statuses",
title: "Mixed Event Statuses",
description: "Events with different booking statuses",
expected:
"Visual styling should differ by status (ACCEPTED, PENDING, CANCELLED). Cascade should still work.",
events: [
{
id: 27,
title: "Confirmed Meeting",
start: makeDate(0, 14, 0),
end: makeDate(0, 15, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 28,
title: "Pending Approval",
start: makeDate(0, 14, 30),
end: makeDate(0, 15, 30),
options: { status: "PENDING", color: "#f59e0b" },
},
{
id: 29,
title: "Cancelled Meeting",
start: makeDate(0, 15, 0),
end: makeDate(0, 16, 0),
options: { status: "CANCELLED", color: "#ef4444" },
},
],
},
{
id: "event-durations",
title: "Event Duration Layout Tests",
description: "Events with different durations to test layout logic (eventDuration > 30 changes flex-col)",
expected:
"Events ≤30min show horizontal layout (title and time inline). Events >30min show vertical layout (title and time stacked).",
events: [
{
id: 40,
title: "3min Quick Chat",
start: makeDate(0, 9, 0),
end: makeDate(0, 9, 3),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 41,
title: "7min Standup",
start: makeDate(0, 9, 15),
end: makeDate(0, 9, 22),
options: { status: "ACCEPTED", color: "#10b981" },
},
{
id: 42,
title: "15min Check-in",
start: makeDate(0, 9, 30),
end: makeDate(0, 9, 45),
options: { status: "PENDING", color: "#f59e0b" },
},
{
id: 43,
title: "20min Review",
start: makeDate(0, 10, 0),
end: makeDate(0, 10, 20),
options: { status: "ACCEPTED", color: "#8b5cf6" },
},
{
id: 44,
title: "30min Discussion",
start: makeDate(0, 10, 30),
end: makeDate(0, 11, 0),
options: { status: "ACCEPTED", color: "#ec4899" },
},
{
id: 45,
title: "32min Discussion",
start: makeDate(0, 11, 10),
end: makeDate(0, 11, 42),
options: { status: "ACCEPTED", color: "#ec4899" },
},
{
id: 46,
title: "40min Discussion",
start: makeDate(0, 12, 0),
end: makeDate(0, 12, 40),
options: { status: "ACCEPTED", color: "#ec4899" },
},
{
id: 47,
title: "53min Workshop",
description: "Learn about the latest trends in web development",
start: makeDate(0, 13, 0),
end: makeDate(0, 13, 53),
options: { status: "ACCEPTED", color: "#14b8a6" },
},
{
id: 48,
title: "90min Workshop",
description: "Learn about the latest trends in web development",
start: makeDate(0, 14, 0),
end: makeDate(0, 15, 30),
options: { status: "ACCEPTED", color: "#14b8a6" },
},
],
},
];
function ScenarioCard({ scenario }: { scenario: Scenario }) {
const [showData, setShowData] = useState(false);
return (
<div className="border-subtle rounded-lg border p-4">
<div className="mb-4">
<h3 className="text-lg font-semibold">{scenario.title}</h3>
<p className="text-subtle mt-1 text-sm">{scenario.description}</p>
<div className="bg-subtle mt-2 rounded p-2 text-xs">
<strong>Expected:</strong> {scenario.expected}
</div>
</div>
<div className="h-[600px] overflow-hidden rounded border">
<Calendar {...getBaseProps(scenario.events)} />
</div>
<button
onClick={() => setShowData(!showData)}
className="text-emphasis mt-2 text-sm underline hover:no-underline">
{showData ? "Hide" : "Show"} Event Data
</button>
{showData && (
<pre className="bg-subtle mt-2 overflow-auto rounded p-2 text-xs">
{JSON.stringify(scenario.events, null, 2)}
</pre>
)}
</div>
);
}
export default function WeeklyCalendarPlayground() {
return (
<div className="space-y-8 p-6">
<div>
<h1 className="text-3xl font-bold">Weekly Calendar Playground</h1>
<p className="text-subtle mt-2">
Test the overlapping events cascading layout with various scenarios. Events should cascade with 80%
width and 8% left offset per overlap level. Hovering an event should bring it to the front.
</p>
</div>
{/* Grid View of All Scenarios */}
<div className="space-y-8">
<h2 className="mb-4 text-2xl font-bold">All Scenarios (Side-by-Side)</h2>
{scenarios.map((scenario, index) => (
<ScenarioCard key={index} scenario={scenario} />
))}
</div>
{/* Testing Checklist */}
<div className="border-subtle rounded-lg border p-6">
<h2 className="mb-4 text-xl font-bold">Testing Checklist</h2>
<ul className="text-subtle space-y-2 text-sm">
<li> Visual appearance matches expectations (80% width with 8% cascading offsets)</li>
<li> Hover behavior works smoothly - hovered event appears topmost</li>
<li> No visual glitches with 3+ overlapping events</li>
<li> Performance is acceptable with dense event days (10+ events)</li>
<li> Edge case: Events with identical start times render correctly</li>
<li> Edge case: Events that touch exactly (end = next start) don&apos;t incorrectly overlap</li>
<li> Different booking statuses (ACCEPTED, PENDING, CANCELLED) display correctly</li>
<li> Color bars on the left side of events display correctly</li>
</ul>
</div>
</div>
);
}
@@ -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}`