Files
calendar/packages/features/calendars/weeklyview/utils/overlap.test.ts
T
Eunjae LeeandGitHub 251d29f65b fix: improve overlapping events with dynamic offsets and widths (#25310)
## 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
2025-11-24 12:01:05 +00:00

600 lines
17 KiB
TypeScript

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(99.5);
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(49.5);
expect(layouts[1].widthPercent).toBe(50);
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).toBeCloseTo(35.315, 1);
expect(layouts[2].leftOffsetPercent).toBe(66.5);
expect(layouts[0].baseZIndex).toBe(60);
expect(layouts[1].baseZIndex).toBe(61);
expect(layouts[2].baseZIndex).toBe(62);
});
it("should respect custom baseZIndex 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, {
baseZIndex: 50,
});
expect(layouts[0].baseZIndex).toBe(50);
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 use dynamic width 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[0].widthPercent).toBe(55);
expect(layouts[1].widthPercent).toBeCloseTo(41.9, 0);
expect(layouts[2].widthPercent).toBe(33);
expect(layouts[0].leftOffsetPercent).toBe(0);
expect(layouts[1].leftOffsetPercent).toBeCloseTo(35.315, 1);
expect(layouts[2].leftOffsetPercent).toBe(66.5);
});
});
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(49.5);
expect(map.get(1)?.widthPercent).toBe(80);
expect(map.get(2)?.widthPercent).toBe(50);
});
it("should handle empty layouts", () => {
const map = createLayoutMap([]);
expect(map.size).toBe(0);
});
});
});