diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/page.tsx
index 5a92c6dbea875b..1e5aca7f724b80 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/page.tsx
@@ -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() {
diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/weekly-calendar/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/weekly-calendar/page.tsx
new file mode 100644
index 00000000000000..b172faae3093b5
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/weekly-calendar/page.tsx
@@ -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 (
+
+
+
{scenario.title}
+
{scenario.description}
+
+ Expected: {scenario.expected}
+
+
+
+
+
+
+
+
setShowData(!showData)}
+ className="text-emphasis mt-2 text-sm underline hover:no-underline">
+ {showData ? "Hide" : "Show"} Event Data
+
+
+ {showData && (
+
+ {JSON.stringify(scenario.events, null, 2)}
+
+ )}
+
+ );
+}
+
+export default function WeeklyCalendarPlayground() {
+ return (
+
+
+
Weekly Calendar Playground
+
+ 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.
+
+
+
+ {/* Grid View of All Scenarios */}
+
+
All Scenarios (Side-by-Side)
+ {scenarios.map((scenario, index) => (
+
+ ))}
+
+
+ {/* Testing Checklist */}
+
+
Testing Checklist
+
+ ✓ Visual appearance matches expectations (80% width with 8% cascading offsets)
+ ✓ Hover behavior works smoothly - hovered event appears topmost
+ ✓ No visual glitches with 3+ overlapping events
+ ✓ Performance is acceptable with dense event days (10+ events)
+ ✓ Edge case: Events with identical start times render correctly
+ ✓ Edge case: Events that touch exactly (end = next start) don't incorrectly overlap
+ ✓ Different booking statuses (ACCEPTED, PENDING, CANCELLED) display correctly
+ ✓ Color bars on the left side of events display correctly
+
+
+
+ );
+}
diff --git a/packages/features/calendars/weeklyview/components/Calendar.tsx b/packages/features/calendars/weeklyview/components/Calendar.tsx
index 520641124186d7..5b45f5081c9ac5 100644
--- a/packages/features/calendars/weeklyview/components/Calendar.tsx
+++ b/packages/features/calendars/weeklyview/components/Calendar.tsx
@@ -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(null);
const containerNav = useRef(null);
const containerOffset = useRef(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 | null>(null);
+
+ if (!storeRef.current) {
+ storeRef.current = createCalendarStore();
+ storeRef.current.getState().initState(props);
+ }
+
+ useEffect(() => {
+ if (storeRef.current) {
+ storeRef.current.getState().initState(props);
+ }
+ }, [props]);
+
+ return (
+
+
+
+ );
+}
+
/** @todo Will be removed once we have mobile support */
const MobileNotSupported = ({ children }: { children: React.ReactNode }) => {
return (
diff --git a/packages/features/calendars/weeklyview/components/event/Event.tsx b/packages/features/calendars/weeklyview/components/event/Event.tsx
index d4e10871327118..ee7ae70ee1ca81 100644
--- a/packages/features/calendars/weeklyview/components/event/Event.tsx
+++ b/packages/features/calendars/weeklyview/components/event/Event.tsx
@@ -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 = (
+
+
+ {options?.color && (
+
+ )}
+
+
{event.title}
+ {!event.options?.hideTime && (
+
+ {dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
+
+ )}
+ {event.description && (
+
{event.description}
+ )}
+ {options?.status && options.status !== "ACCEPTED" && (
+
+ {options.status.toLowerCase().replace("_", " ")}
+
+ )}
+
+
+
+ );
+
+ const displayType = eventDuration < 40 ? "single-line" : eventDuration < 45 ? "multi-line" : "full";
+
return (
-
+
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 && (
)}
- 30 && "flex-col py-1")}>
-
-
{event.title}
- {eventDuration <= 30 && !event.options?.hideTime && (
-
- {dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
-
- )}
-
- {eventDuration > 30 && !event.options?.hideTime && (
-
+
+ {displayType === "single-line" && (
+
+
{event.title}
+ {!event.options?.hideTime && (
+
+ {dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
+
+ )}
+
+ )}
+ {displayType !== "single-line" && (
+
{event.title}
+ )}
+ {displayType !== "single-line" && !event.options?.hideTime && (
+
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
)}
- {eventDuration > 45 && event.description && (
+ {displayType === "full" && event.description && (
{event.description}
)}
diff --git a/packages/features/calendars/weeklyview/components/event/EventList.tsx b/packages/features/calendars/weeklyview/components/event/EventList.tsx
index 609f56dae2e8a3..0bfe8f6bf2a671 100644
--- a/packages/features/calendars/weeklyview/components/event/EventList.tsx
+++ b/packages/features/calendars/weeklyview/components/event/EventList.tsx
@@ -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
(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;
+ 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 eventStart = dayjs(event.start);
- const eventEnd = dayjs(event.end);
+ 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();
- const eventDuration = eventEnd.diff(eventStart, "minutes");
+ return [
+ event.id,
+ {
+ eventStart,
+ eventDuration,
+ eventStartDiff,
+ },
+ ];
+ })
+ );
+ }, [dayEvents, startHour]);
- const eventStartHour = eventStart.hour();
- const eventStartDiff = (eventStartHour - (startHour || 0)) * 60 + eventStart.minute();
- const nextEvent = eventsArray[idx + 1];
- const prevEvent = eventsArray[idx - 1];
+ // Find which overlap group the hovered event belongs to
+ const hoveredEventLayout = hoveredEventId ? layoutMap.get(hoveredEventId) : null;
+ const hoveredGroupIndex = hoveredEventLayout?.groupIndex ?? null;
- 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;
+ return (
+ <>
+ {dayEvents.map((event) => {
+ const layout = layoutMap.get(event.id);
+ if (!layout) return null;
- // 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);
+ const calc = eventCalculations.get(event.id);
+ if (!calc) return null;
- // If the next event is inbetween the longest start and end make 65% width
+ const { eventStart, eventDuration, eventStartDiff } = calc;
- 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;
- }
- }
- }
+ const isHovered = hoveredEventId === event.id;
+ const isInHoveredGroup = hoveredGroupIndex !== null && layout.groupIndex === hoveredGroupIndex;
+ const zIndex = isHovered ? 100 : layout.baseZIndex;
- return (
-
-
-
- );
- })}
+ return (
+ 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,
+ }}>
+
+
+ );
+ })}
>
);
}
diff --git a/packages/features/calendars/weeklyview/state/store.ts b/packages/features/calendars/weeklyview/state/store.ts
index 97df1159c64909..5776328b589a2d 100644
--- a/packages/features/calendars/weeklyview/state/store.ts
+++ b/packages/features/calendars/weeklyview/state/store.ts
@@ -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((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): StoreApi {
+ return createStore((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)
+ ) {
+ return {
+ startDate,
+ endDate,
+ };
+ }
- // Do nothing if
- if (
- (state.minDate && newStartDate < state.minDate) ||
- (state.maxDate && newEndDate > state.maxDate)
- ) {
+ // 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 | null>(null);
+
+export function useCalendarStore(
+ 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);
+}
diff --git a/packages/features/calendars/weeklyview/utils/overlap.test.ts b/packages/features/calendars/weeklyview/utils/overlap.test.ts
new file mode 100644
index 00000000000000..78fbc0bd9bd42b
--- /dev/null
+++ b/packages/features/calendars/weeklyview/utils/overlap.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/packages/features/calendars/weeklyview/utils/overlap.ts b/packages/features/calendars/weeklyview/utils/overlap.ts
new file mode 100644
index 00000000000000..c473222cb13783
--- /dev/null
+++ b/packages/features/calendars/weeklyview/utils/overlap.ts
@@ -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 = {
+ 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 {
+ const map = new Map();
+ layouts.forEach((layout) => {
+ map.set(layout.event.id, layout);
+ });
+ return map;
+}
diff --git a/packages/ui/components/tooltip/Tooltip.tsx b/packages/ui/components/tooltip/Tooltip.tsx
index 33c2af485f9f2f..991ac855419890 100644
--- a/packages/ui/components/tooltip/Tooltip.tsx
+++ b/packages/ui/components/tooltip/Tooltip.tsx
@@ -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}`