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} +
+
+ +
+ +
+ + + + {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

+ +
+
+ ); +} 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}`