From 251d29f65bf1a28467ec4deb4fe823dbe6fc63da Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Mon, 24 Nov 2025 13:01:05 +0100 Subject: [PATCH] fix: improve overlapping events with dynamic offsets and widths (#25310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What does this PR do? Improves the visual presentation of overlapping calendar events in the weekly view with two key enhancements: - **Dynamic startHour per scenario**: Each playground scenario now displays the calendar starting at an appropriate hour based on its earliest event time, rather than always starting at 6am - **Full width for non-overlapping events**: Single events and non-overlapping events now display at 100% width (previously 80%) for maximum visibility ## Key Changes ### Overlapping Event Layout Algorithm Replaces the previous uniform-width, fixed-offset layout with an intelligent spread algorithm: **Previous behavior:** - All overlapping events: 80% width with 8% offset steps - Events clustered on the left side **New behavior:** - **2 overlapping events**: 80% and 50% widths - **3 overlapping events**: 55%, ~42%, and 33% widths - **4+ overlapping events**: Progressive narrowing from 40% down to minimum 25% - **Spread algorithm**: Events distribute across the full width with the last event aligned to the right edge - **Right edge distribution**: `ri = Rmin + (Rmax - Rmin) × i/(n-1)` for even spacing ### Visual Improvements - Single/non-overlapping events: **100% width** (was 80%) - Overlapping events: **Variable widths** based on cascade position (leftmost events wider, rightmost narrower) - Last overlapping event: **Aligned to right border** for maximum scatter - Minimum width: **25%** maintained for readability **Devin session:** https://app.devin.ai/sessions/168d2227f5304c49ae4d34d17da5b025 **Requested by:** eunjae@cal.com (@eunjae-lee) ## Visual Demo https://github.com/user-attachments/assets/693546fa-448d-470a-b041-c08f4697c177 ## Mandatory Tasks (DO NOT REMOVE) - [x] I have self-reviewed the code - [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. **N/A** - playground-only changes - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? 1. Navigate to `/settings/admin/playground/weekly-calendar` 2. Verify each scenario: - **Non-Overlapping Events**: Both events should be 100% width, no offset - **Touching Events**: Both events should be 100% width, no offset - **Two Overlapping Events**: First event 80% width, second 50% width aligned to right - **Three Overlapping Events**: Progressive narrowing with spread across full width - **Four Overlapping Events**: Four events spread across full width 3. Verify startHour values: - Most scenarios should start at 9am (events start at 10am) - Dense day scenario should start at 8am (events start at 9am) - Mixed statuses scenario should start at 1pm (events start at 2pm) 4. Test with real calendar data to ensure overlapping events look visually distinct **Environment variables:** Standard Cal.com development setup **Test data:** Use playground scenarios or create overlapping events in your calendar ## Human Review Checklist **⚠️ CRITICAL ITEMS:** 1. **Visual verification in playground** (MOST IMPORTANT): - Open `/settings/admin/playground/weekly-calendar` - Verify non-overlapping events are 100% width (not 80%) - Verify overlapping events spread properly across full width - Verify last overlapping event aligns to right edge - Verify each scenario starts at appropriate hour 2. **Algorithm correctness**: - Single events: 100% width (was 80%) - Two overlapping: 80%, 50% widths with last aligned to right - Three overlapping: 55%, ~42%, 33% widths spread across full width - Right-edge distribution: `ri = Rmin + (Rmax - Rmin) * i/(n-1)` 3. **Edge cases**: - Test with 10+ overlapping events to ensure no overflow - Verify minimum width (25%) is respected - Verify backward compatibility: custom `baseWidthPercent`/`offsetStepPercent` should use legacy behavior 4. **Type safety**: - `startHour` parameter now properly typed as `Hours` (union of 0-23) - All scenarios use valid `Hours` values **Known limitations:** - Local visual testing was not completed due to environment issues - Easing curve parameters (curveExponent: 1.3) were chosen based on examples but may need visual tuning - No E2E tests for visual appearance (only unit tests for layout calculations) ## Checklist - [x] I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - [x] My code follows the style guidelines of this project - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have checked if my changes generate no new warnings --- .../admin/playground/weekly-calendar/page.tsx | 57 ++- .../weeklyview/components/Calendar.tsx | 3 +- .../components/DateValues/index.tsx | 2 +- .../components/currentTime/index.tsx | 12 +- .../weeklyview/components/grid/index.tsx | 2 +- .../components/verticalLines/index.tsx | 2 +- .../weeklyview/utils/overlap.test.ts | 413 ++++++++++++++---- .../calendars/weeklyview/utils/overlap.ts | 114 +++-- 8 files changed, 467 insertions(+), 138 deletions(-) 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 index b172faae30..9e3b3f08a8 100644 --- 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 @@ -5,17 +5,23 @@ 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"; +import type { CalendarComponentProps, Hours } 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 => ({ +const getBaseProps = ({ + events, + startHour = 6, +}: { + events: CalendarEvent[]; + startHour?: Hours; +}): CalendarComponentProps => ({ startDate: dayjs("2025-01-06").toDate(), // Monday endDate: dayjs("2025-01-12").toDate(), // Sunday events, - startHour: 6, + startHour, endHour: 18, gridCellsPerHour: 4, timezone: "UTC", @@ -23,6 +29,7 @@ const getBaseProps = (events: CalendarEvent[]): CalendarComponentProps => ({ showBorder: false, hideHeader: true, borderColor: "subtle", + scrollToCurrentTime: false, }); type Scenario = { @@ -31,6 +38,7 @@ type Scenario = { description: string; expected: string; events: CalendarEvent[]; + startHour: Hours; }; const scenarios: Scenario[] = [ @@ -39,7 +47,8 @@ const scenarios: Scenario[] = [ 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.", + "First event 80% width at left edge (0%), second event 50% width aligned to right edge (49.5% offset). Events spread across full width for maximum visual distinction. Hover should bring event to front.", + startHour: 9, events: [ { id: 1, @@ -62,7 +71,8 @@ const scenarios: Scenario[] = [ 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 spread across full width with variable widths (55%, ~42%, 33%). Offsets: 0%, ~35%, 66.5% (last event aligned to right edge). Right edges evenly distributed for maximum scatter. Z-index should increment. Hover brings any to top.", + startHour: 9, events: [ { id: 3, @@ -91,7 +101,8 @@ const scenarios: Scenario[] = [ 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.", + expected: "Both events at 0% offset (separate groups), no cascade. Both should be 100% width.", + startHour: 9, events: [ { id: 6, @@ -113,7 +124,9 @@ const scenarios: Scenario[] = [ 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.", + expected: + "Longest event first (base of cascade), spread across full width with variable widths (55%, ~42%, 33%). Last event aligned to right edge. All start at 10:00.", + startHour: 9, events: [ { id: 8, @@ -139,16 +152,18 @@ const scenarios: Scenario[] = [ ], }, { - 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%.", + id: "four-overlapping", + title: "Four Overlapping Events", + description: "Four events that overlap simultaneously", + expected: + "Events spread across full width with variable widths (40%, ~33%, ~28%, 25%). Last event aligned to right edge. Right edges evenly distributed for maximum scatter.", + startHour: 9, events: [ { id: 11, title: "Event A", start: makeDate(4, 10, 0), - end: makeDate(4, 11, 0), + end: makeDate(4, 12, 0), options: { status: "ACCEPTED", color: "#3b82f6" }, }, { @@ -162,9 +177,16 @@ const scenarios: Scenario[] = [ id: 13, title: "Event C", start: makeDate(4, 11, 0), - end: makeDate(4, 12, 0), + end: makeDate(4, 12, 30), options: { status: "ACCEPTED", color: "#10b981" }, }, + { + id: 50, + title: "Event D", + start: makeDate(4, 11, 15), + end: makeDate(4, 12, 15), + options: { status: "PENDING", color: "#8b5cf6" }, + }, ], }, { @@ -173,6 +195,7 @@ const scenarios: Scenario[] = [ 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.", + startHour: 8, events: [ { id: 14, @@ -327,7 +350,9 @@ const scenarios: Scenario[] = [ 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.", + expected: + "Separate groups; no cascade; both at 0% offset. Both should be 100% width. Events touching at 11:00 should not overlap.", + startHour: 9, events: [ { id: 25, @@ -351,6 +376,7 @@ const scenarios: Scenario[] = [ description: "Events with different booking statuses", expected: "Visual styling should differ by status (ACCEPTED, PENDING, CANCELLED). Cascade should still work.", + startHour: 13, events: [ { id: 27, @@ -381,6 +407,7 @@ const scenarios: Scenario[] = [ 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).", + startHour: 8, events: [ { id: 40, @@ -465,7 +492,7 @@ function ScenarioCard({ scenario }: { scenario: Scenario }) {
- +