Repository: SwitchbackTech/compass
Pull request #234 (2025-01-28 03:54:29)

✨ Feat(web): handle overlapping events
This PR handles detecting and displaying overlapping events so that they're not on top of one another, effectively being hidden and causing bad UX when attempting to view or drag/edit them.

[handle_overlapping_events.webm](https://github.com/user-attachments/assets/5d20983c-eed9-44a2-b3ee-9ae5e4800a5e)


that-one-arab: @tyler-dane This PR is ready to be merged, but I prefer implementing unit tests before merging, seeking your guidance on how to proceed here.
tyler-dane: Upon first load, this is what I'm seeing. 

<img width="1920" alt="isOverlapping" src="https://github.com/user-attachments/assets/e895dcb4-f579-4f84-83c8-931f92114e35" />

After changing  `event.position.isOverlapping && ...` to `event.position?.isOverlapping && ...` in `grid.util.ts:getAbsoluteLeftPosition` it went away. 

I saw that you made `event.position` required and I wasn't getting a type error on that line, but something must still be wrong.

We can't merge it with this issue - can you investigate?
tyler-dane: Another blocking issue: The form position isn't working for grid events. They're always in the top left now.
<img width="1910" alt="form" src="https://github.com/user-attachments/assets/d49adfc7-373c-44c8-b8fc-ae7a9f5300a2" />

tyler-dane: > @tyler-dane This PR is ready to be merged, but I prefer implementing unit tests before merging, seeking your guidance on how to proceed here.

Agree, let's add some testing for this new algorithm. 

I'd focus on testing `adjustOverlappingEvents`, since that works independent of our store and selector implementation. Add tests to validate:
- sorting by time
- sorting by title as fallback

Don't try to validate `isOverlapping` or `widthMultiplier`. We only care that the `horizontal` order is correct for any given array of events. 
that-one-arab: > Another blocking issue: The form position isn't working for grid events. They're always in the top left now. <img alt="form" width="1910" src="https://private-user-images.githubusercontent.com/30163055/407334972-d49adfc7-373c-44c8-b8fc-ae7a9f5300a2.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MzgxMjIzMjIsIm5iZiI6MTczODEyMjAyMiwicGF0aCI6Ii8zMDE2MzA1NS80MDczMzQ5NzItZDQ5YWRmYzctMzczYy00NGM4LWI4ZmMtYWU3YTlmNTMwMGEyLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAxMjklMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMTI5VDAzNDAyMlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTNjZWJlNTIyODViMWI3N2I4NWExYjU5ODlhN2NmZmVkMDVlMTYzMTRhM2EyNDc3NmZkNTIxYTk0NjRmZThiOWUmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.RC2jjpiJHHfN_X-Hvx_n6viRJJ1g2yMjwdp1V71Oa14">

I was unable to reproduce both issues reported by you, could you please share reproduction steps with me?
tyler-dane: > > Another blocking issue: The form position isn't working for grid events. They're always in the top left now. <img alt="form" width="1910" src="https://private-user-images.githubusercontent.com/30163055/407334972-d49adfc7-373c-44c8-b8fc-ae7a9f5300a2.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MzgxMjIzMjIsIm5iZiI6MTczODEyMjAyMiwicGF0aCI6Ii8zMDE2MzA1NS80MDczMzQ5NzItZDQ5YWRmYzctMzczYy00NGM4LWI4ZmMtYWU3YTlmNTMwMGEyLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAxMjklMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMTI5VDAzNDAyMlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTNjZWJlNTIyODViMWI3N2I4NWExYjU5ODlhN2NmZmVkMDVlMTYzMTRhM2EyNDc3NmZkNTIxYTk0NjRmZThiOWUmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.RC2jjpiJHHfN_X-Hvx_n6viRJJ1g2yMjwdp1V71Oa14">
> 
> I was unable to reproduce both issues reported by you, could you please share reproduction steps with me?

I just checked out to this PR in the CLI and tried loading the app in dev. 

Try logging out or creating more events from `main` and then coming back to this PR's branch to reproduce.


https://github.com/user-attachments/assets/2c5351f1-2cfc-4e54-b4f7-7963e39b29e5

https://github.com/user-attachments/assets/b77ab03c-a37e-469a-b5eb-03e0be288b20



that-one-arab: @tyler-dane Still couldn't reproduce it. I noticed that my branch was behind in some changes, I thought maybe this was the reason, but even after pulling and rebasing I still could not reproduce
[bug_reproduce_attempt.webm](https://github.com/user-attachments/assets/8d23194f-e21c-4097-bfa7-bb88ca337049)

Maybe we can set up a meeting to try and reproduce it live?
that-one-arab: @tyler-dane I did some forced updating and finalized my refactors and unit tests. Please let me know if you are still reproducing this, if yes, maybe we can schedule a meeting to see this bug in action
tyler-dane: > @tyler-dane I did some forced updating and finalized my refactors and unit tests. Please let me know if you are still reproducing this, if yes, maybe we can schedule a meeting to see this bug in action

I have a feeling it's related to how you're testing. Perhaps you're using a local account without any pre-existing calendar events? 

Regardless, I want to avoid scheduling a debugging meeting due to my restrictive schedule. I pushed up a commit to make that check optional, which is 'fixing' it for me. I'd like to get this PR in so you can start on #140. Going to merge this and do a little more debugging on my end; might do a follow-up cleanup PR if I find anything that'd help the error I was seeing. 

Overall nice job on the functionality, tests, and timeliness! 

Patch:
diff --git a/packages/web/src/common/types/web.event.types.ts b/packages/web/src/common/types/web.event.types.ts
index 2e9540cc..40afb42a 100644
--- a/packages/web/src/common/types/web.event.types.ts
+++ b/packages/web/src/common/types/web.event.types.ts
@@ -22,6 +22,11 @@ export interface Schema_GridEvent extends Schema_Event {
   hasFlipped?: boolean;
   isOpen?: boolean;
   row?: number; //TODO: delete if not used
+  position: {
+    isOverlapping: boolean;
+    widthMultiplier: number; // EG: 0.5 for half width
+    horizontalOrder: number;
+  };
 }
 
 export interface Schema_OptimisticEvent extends Schema_Event {
diff --git a/packages/web/src/common/utils/event.util.ts b/packages/web/src/common/utils/event.util.ts
index 2df669bf..f8fe04f6 100644
--- a/packages/web/src/common/utils/event.util.ts
+++ b/packages/web/src/common/utils/event.util.ts
@@ -85,23 +85,58 @@ export const getCategory = (event: Schema_Event) => {
   return Categories_Event.TIMED;
 };
 
+export const assembleGridEvent = (
+  event: Partial<Schema_GridEvent>
+): Schema_GridEvent => {
+  // TODO: Maybe move to a constants file?
+  const DEFAULT_POSITION = {
+    isOverlapping: false,
+    widthMultiplier: 1,
+    horizontalOrder: 1,
+  };
+
+  return {
+    _id: event._id || "",
+    title: event.title || "",
+    description: event.description || "",
+    startDate: event.startDate || "",
+    endDate: event.endDate || "",
+    user: event.user || "",
+    isAllDay: event.isAllDay || false,
+    isSomeday: event.isSomeday || false,
+    origin: event.origin || Origin.COMPASS,
+    priority: event.priority || Priorities.UNASSIGNED,
+    position: event.position || DEFAULT_POSITION,
+  };
+};
+
 export const getDefaultEvent = (
   draftType: Categories_Event,
   startDate?: string,
   endDate?: string
 ): Schema_GridEvent | null => {
+  const defaultEvent = assembleGridEvent({
+    priority: Priorities.UNASSIGNED,
+    position: {
+      isOverlapping: false,
+      widthMultiplier: 1,
+      horizontalOrder: 1,
+    },
+  });
+
   const defaultSomeday = {
+    ...defaultEvent,
     isAllDay: false,
     isSomeday: true,
     origin: Origin.COMPASS,
-    priority: Priorities.UNASSIGNED,
   };
+
   switch (draftType) {
     case Categories_Event.ALLDAY:
       return {
+        ...defaultEvent,
         isAllDay: true,
         isSomeday: false,
-        priority: Priorities.UNASSIGNED,
         startDate,
         endDate: startDate,
       };
@@ -109,9 +144,9 @@ export const getDefaultEvent = (
       return defaultSomeday;
     case Categories_Event.TIMED: {
       return {
+        ...defaultEvent,
         isAllDay: false,
         isSomeday: false,
-        priority: Priorities.UNASSIGNED,
         startDate,
         endDate,
       };
@@ -140,10 +175,6 @@ export const handleError = (error: Error) => {
     return;
   }
 
-  console.log(error.message);
-  console.log(error.stack);
-  console.log(error);
-
   if (code === Status.INTERNAL_SERVER) {
     alert("Something went wrong behind the scenes. Please try again later.");
     window.location.reload();
@@ -216,3 +247,90 @@ export const replaceIdWithOptimisticId = (
 
   return _event;
 };
+
+export const adjustOverlappingEvents = (
+  events: Schema_GridEvent[]
+): Schema_GridEvent[] => {
+  // Deep copy events
+  let adjustedEvents = events.map((event) => ({
+    ...event,
+    position: { ...event.position },
+  }));
+
+  // Sort by start time first
+  adjustedEvents.sort((a, b) => dayjs(a.startDate).diff(dayjs(b.startDate)));
+
+  const processedEvents = new Set<string>();
+
+  // Helper function to find all overlapping events recursively
+  const findAllOverlappingEvents = (
+    baseEvent: Schema_GridEvent,
+    accumulatedEvents = new Set<Schema_GridEvent>()
+  ): Set<Schema_GridEvent> => {
+    const directOverlaps = adjustedEvents.filter(
+      (otherEvent) =>
+        otherEvent !== baseEvent && // Skip itself
+        !accumulatedEvents.has(otherEvent) && // Skip if already processed
+        dayjs(baseEvent.startDate).isBefore(dayjs(otherEvent.endDate)) &&
+        dayjs(baseEvent.endDate).isAfter(dayjs(otherEvent.startDate))
+    );
+
+    directOverlaps.forEach((event) => {
+      accumulatedEvents.add(event);
+      // Recursively find overlaps for each overlapping event
+      findAllOverlappingEvents(event, accumulatedEvents);
+    });
+
+    return accumulatedEvents;
+  };
+
+  for (let i = 0; i < adjustedEvents.length; i++) {
+    const targetEvent = adjustedEvents[i];
+
+    // Skip if already processed
+    if (processedEvents.has(targetEvent._id)) {
+      continue;
+    }
+
+    // Find all overlapping events recursively
+    const overlappingEventsSet = findAllOverlappingEvents(
+      targetEvent,
+      new Set([targetEvent])
+    );
+    const eventGroup = Array.from(overlappingEventsSet);
+
+    if (eventGroup.length > 1) {
+      // If there are any overlaps, calculate width multiplier
+      let multiplier = 1 / eventGroup.length;
+      // Round to 2 decimal places (in case we have way too many decimal places from the division)
+      multiplier = Math.round(multiplier * 100) / 100;
+
+      // Set adjustments for all events in the group
+      eventGroup.forEach((event, i) => {
+        event.position.isOverlapping = true;
+        event.position.widthMultiplier *= multiplier;
+        event.position.horizontalOrder = i + 1;
+        processedEvents.add(event._id);
+      });
+
+      // If exact start and end times match, sort alphabetically by title
+      if (
+        eventGroup.every(
+          (event) =>
+            dayjs(event.startDate).isSame(targetEvent.startDate) &&
+            dayjs(event.endDate).isSame(targetEvent.endDate)
+        )
+      ) {
+        eventGroup.sort((a, b) => {
+          if (!a.title || !b.title) {
+            return 0;
+          }
+
+          return a.title.localeCompare(b.title);
+        });
+      }
+    }
+  }
+
+  return adjustedEvents;
+};
diff --git a/packages/web/src/common/utils/grid.util.ts b/packages/web/src/common/utils/grid.util.ts
index 274dade5..17459950 100644
--- a/packages/web/src/common/utils/grid.util.ts
+++ b/packages/web/src/common/utils/grid.util.ts
@@ -18,6 +18,7 @@ import {
   GRID_X_PADDING_TOTAL,
   SIDEBAR_OPEN_WIDTH,
 } from "@web/views/Calendar/layout.constants";
+import { Schema_GridEvent } from "@web/common/types/web.event.types";
 
 dayjs.extend(dayOfYear);
 dayjs.extend(weekPlugin);
@@ -399,9 +400,17 @@ export const getEventCategory = (
 export const getLeftPosition = (
   category: Category,
   startIndex: number,
-  widths: number[]
+  colWidths: number[],
+  event?: Schema_GridEvent,
+  eventWidth?: number
 ) => {
-  const left = getAbsoluteLeftPosition(category, startIndex, widths);
+  const left = getAbsoluteLeftPosition(
+    category,
+    startIndex,
+    colWidths,
+    event,
+    eventWidth
+  );
 
   return left;
 };
@@ -409,7 +418,9 @@ export const getLeftPosition = (
 export const getAbsoluteLeftPosition = (
   category: Category,
   startIndex: number,
-  widths: number[]
+  colWidths: number[],
+  event?: Schema_GridEvent,
+  eventWidth?: number
 ) => {
   let positionStart: number;
   switch (category) {
@@ -422,9 +433,20 @@ export const getAbsoluteLeftPosition = (
     case Category.ThisToFutureWeek:
       {
         // add up from 0 index to startIndex
-        positionStart = widths.reduce((accum, width, index) => {
+        positionStart = colWidths.reduce((accum, width, index) => {
           return index < startIndex ? accum + width : accum;
         }, 0);
+
+        if (!event || !eventWidth) {
+          return positionStart;
+        }
+
+        if (
+          event.position?.isOverlapping &&
+          event.position.horizontalOrder > 1
+        ) {
+          positionStart += eventWidth * (event.position.horizontalOrder - 1);
+        }
       }
       break;
     default: {
diff --git a/packages/web/src/ducks/events/selectors/event.selectors.ts b/packages/web/src/ducks/events/selectors/event.selectors.ts
index 8a957420..5594f362 100644
--- a/packages/web/src/ducks/events/selectors/event.selectors.ts
+++ b/packages/web/src/ducks/events/selectors/event.selectors.ts
@@ -3,6 +3,7 @@ import { Schema_Event } from "@core/types/event.types";
 import { RootState } from "@web/store";
 import { assignEventsToRow } from "@web/common/utils/grid.util";
 import { Schema_GridEvent } from "@web/common/types/web.event.types";
+import { assembleGridEvent } from "@web/common/utils/event.util";
 
 export const selectAllDayEvents = createSelector(
   (state: RootState) => state.events.entities.value || {},
@@ -34,9 +35,9 @@ export const selectGridEvents = createSelector(
     if (!("data" in weekIds) || weekIds.data.length === 0) return [];
     const weekEventsMapped = weekIds.data.map((_id: string) => entities[_id]);
 
-    const weekEvents = weekEventsMapped.filter(
-      (e: Schema_Event) => e !== undefined && !e.isAllDay
-    );
+    const weekEvents: Schema_GridEvent[] = weekEventsMapped
+      .filter((e: Schema_Event) => e !== undefined && !e.isAllDay)
+      .map(assembleGridEvent);
 
     return weekEvents;
   }
diff --git a/packages/web/src/views/Calendar/components/Grid/MainGrid/MainGridEvents.tsx b/packages/web/src/views/Calendar/components/Grid/MainGrid/MainGridEvents.tsx
index 2b523548..3bd2dcb8 100644
--- a/packages/web/src/views/Calendar/components/Grid/MainGrid/MainGridEvents.tsx
+++ b/packages/web/src/views/Calendar/components/Grid/MainGrid/MainGridEvents.tsx
@@ -11,6 +11,7 @@ import { selectDraftId } from "@web/ducks/events/selectors/draft.selectors";
 import { isEventFormOpen } from "@web/common/utils";
 
 import { GridEventMemo } from "../../Event/Grid/GridEvent/GridEvent";
+import { adjustOverlappingEvents } from "@web/common/utils/event.util";
 
 interface Props {
   measurements: Measurements_Grid;
@@ -23,6 +24,8 @@ export const MainGridEvents = ({ measurements, weekProps }: Props) => {
   const timedEvents = useAppSelector(selectGridEvents);
   const draftId = useAppSelector(selectDraftId);
 
+  const adjustedEvents = adjustOverlappingEvents(timedEvents);
+
   const onMouseDown = (e: MouseEvent, event: Schema_Event) => {
     e.stopPropagation();
     if (isEventFormOpen()) {
@@ -52,7 +55,7 @@ export const MainGridEvents = ({ measurements, weekProps }: Props) => {
 
   return (
     <div id={ID_GRID_EVENTS_TIMED}>
-      {timedEvents.map((event: Schema_GridEvent) => {
+      {adjustedEvents.map((event: Schema_GridEvent) => {
         return (
           <GridEventMemo
             event={event}
diff --git a/packages/web/src/views/Calendar/hooks/event/getPosition.ts b/packages/web/src/views/Calendar/hooks/event/getPosition.ts
index a87f0ee3..d4b60c3b 100644
--- a/packages/web/src/views/Calendar/hooks/event/getPosition.ts
+++ b/packages/web/src/views/Calendar/hooks/event/getPosition.ts
@@ -22,6 +22,8 @@ export const getPosition = (
   measurements: Measurements_Grid,
   isDraft: boolean
 ) => {
+  if (!event) return null; // TS Guard
+
   const { colWidths } = measurements;
   const start = dayjs(event.startDate);
   const end = dayjs(event.endDate);
@@ -29,15 +31,11 @@ export const getPosition = (
   const category = getEventCategory(start, end, startOfView, endOfView);
   const startIndex = start.get("day");
 
-  let left = getLeftPosition(category, startIndex, colWidths);
-
-  if (isDraft || !event.isAllDay) {
-    left += GRID_MARGIN_LEFT;
-  }
-
   let height: number;
   let top: number;
   let width: number;
+  let left: number;
+
   if (event.isAllDay) {
     height = EVENT_ALLDAY_HEIGHT;
     top = 23 * (event?.row || 1); // found by experimenting with what 'looked right'
@@ -50,7 +48,7 @@ export const getPosition = (
       colWidths
     );
   } else {
-    width = colWidths[startIndex];
+    width = colWidths[startIndex] * event.position.widthMultiplier;
     const widthBuffer = 11;
     width -= widthBuffer;
 
@@ -65,6 +63,12 @@ export const getPosition = (
     height -= DRAFT_PADDING_BOTTOM;
   }
 
+  left = getLeftPosition(category, startIndex, colWidths, event, width);
+
+  if (isDraft || !event.isAllDay) {
+    left += GRID_MARGIN_LEFT;
+  }
+
   const position = { height, left, top, width };
   return position;
 };


Test patch:
diff --git a/packages/web/src/common/utils/event.util.test.ts b/packages/web/src/common/utils/event.util.test.ts
index 02d23596..48f038e4 100644
--- a/packages/web/src/common/utils/event.util.test.ts
+++ b/packages/web/src/common/utils/event.util.test.ts
@@ -1,4 +1,8 @@
-import { isEventInRange } from "./event.util";
+import {
+  isEventInRange,
+  adjustOverlappingEvents,
+  assembleGridEvent,
+} from "./event.util";
 
 describe("isEventInRange", () => {
   it("returns true if event is in range", () => {
@@ -18,3 +22,198 @@ describe("isEventInRange", () => {
     expect(isEventInRange(event, dates)).toBe(false);
   });
 });
+
+describe("adjustOverlappingEvents", () => {
+  // Helper function that constructs a date string from hour:minute input.
+  // Makes date time strings easier to read
+  const time = (time: string) => `2025-01-27T${time}:00+03:00`;
+
+  it("Sorts events by start time", () => {
+    const eventA = assembleGridEvent({
+      _id: "A",
+      startDate: time("07:30"),
+      endDate: time("08:00"),
+    });
+    const eventB = assembleGridEvent({
+      _id: "B",
+      startDate: time("07:15"),
+      endDate: time("07:45"),
+    });
+
+    const adjustedEvents = adjustOverlappingEvents([eventA, eventB]);
+
+    expect(adjustedEvents[0].startDate).toBe(eventB.startDate);
+    expect(adjustedEvents[1].startDate).toBe(eventA.startDate);
+  });
+
+  it("Returns the same array if no events overlap", () => {
+    const eventA = assembleGridEvent({
+      _id: "A",
+      startDate: time("07:30"),
+      endDate: time("08:00"),
+    });
+    const eventB = assembleGridEvent({
+      _id: "B",
+      startDate: time("08:00"),
+      endDate: time("08:30"),
+    });
+
+    const adjustedEvents = adjustOverlappingEvents([eventA, eventB]);
+
+    expect(adjustedEvents).toEqual([eventA, eventB]);
+  });
+
+  it("Adjusts events that overlap", () => {
+    const eventA = assembleGridEvent({
+      _id: "A",
+      startDate: time("07:30"),
+      endDate: time("08:00"),
+    });
+    const eventB = assembleGridEvent({
+      _id: "B",
+      startDate: time("07:45"),
+      endDate: time("08:15"),
+    });
+
+    const adjustedEvents = adjustOverlappingEvents([eventA, eventB]);
+
+    expect(adjustedEvents[0].position.isOverlapping).toBe(true);
+    expect(adjustedEvents[0].position.horizontalOrder).toBe(1);
+    expect(adjustedEvents[1].position.isOverlapping).toBe(true);
+    expect(adjustedEvents[1].position.horizontalOrder).toBe(2);
+  });
+
+  it("Does not modify events that do not overlap", () => {
+    const eventA = assembleGridEvent({
+      _id: "A",
+      startDate: time("07:30"),
+      endDate: time("08:00"),
+    });
+    const eventB = assembleGridEvent({
+      _id: "B",
+      startDate: time("07:45"),
+      endDate: time("08:15"),
+    });
+    const nonOverlappingEventC = assembleGridEvent({
+      _id: "C",
+      startDate: time("09:45"),
+      endDate: time("10:15"),
+    });
+
+    const adjustedEvents = adjustOverlappingEvents([
+      eventA,
+      eventB,
+      nonOverlappingEventC,
+    ]);
+
+    expect(adjustedEvents[0].position.isOverlapping).toBe(true);
+    expect(adjustedEvents[0].position.horizontalOrder).toBe(1);
+    expect(adjustedEvents[1].position.isOverlapping).toBe(true);
+    expect(adjustedEvents[1].position.horizontalOrder).toBe(2);
+    // Non-overlapping event should not be modified
+    expect(adjustedEvents[2]).toStrictEqual(nonOverlappingEventC);
+  });
+
+  it("Adjusts events that indirectly overlap", () => {
+    const eventA = assembleGridEvent({
+      _id: "A",
+      startDate: time("07:30"),
+      endDate: time("08:00"),
+    });
+    const eventB = assembleGridEvent({
+      _id: "B",
+      startDate: time("07:45"),
+      endDate: time("08:15"),
+    });
+    const eventC = assembleGridEvent({
+      _id: "C",
+      // `eventC` starts when `eventA` ends, does not directly overlap, but should still be adjusted because it indirectly overlaps due to `eventB`
+      startDate: time("08:00"),
+      endDate: time("08:30"),
+    });
+
+    const adjustedEvents = adjustOverlappingEvents([eventA, eventB, eventC]);
+
+    expect(adjustedEvents[0].position.isOverlapping).toBe(true);
+    expect(adjustedEvents[0].position.horizontalOrder).toBe(1);
+    expect(adjustedEvents[1].position.isOverlapping).toBe(true);
+    expect(adjustedEvents[1].position.horizontalOrder).toBe(2);
+    expect(adjustedEvents[2].position.isOverlapping).toBe(true);
+    expect(adjustedEvents[2].position.horizontalOrder).toBe(3);
+  });
+
+  it("Adjusts events that overlap in 2 distinct groups", () => {
+    // Group 1
+    const eventA = assembleGridEvent({
+      _id: "A",
+      startDate: time("07:30"),
+      endDate: time("08:00"),
+    });
+    const eventB = assembleGridEvent({
+      _id: "B",
+      startDate: time("07:45"),
+      endDate: time("08:15"),
+    });
+    const eventC = assembleGridEvent({
+      _id: "C",
+      startDate: time("08:00"),
+      endDate: time("08:30"),
+    });
+
+    // Group 2
+    const eventD = assembleGridEvent({
+      _id: "D",
+      startDate: time("10:00"),
+      endDate: time("11:00"),
+    });
+    const eventE = assembleGridEvent({
+      _id: "E",
+      startDate: time("10:30"),
+      endDate: time("11:30"),
+    });
+
+    const adjustedEvents = adjustOverlappingEvents([
+      eventA,
+      eventB,
+      eventC,
+      eventD,
+      eventE,
+    ]);
+
+    // Group 1
+    expect(adjustedEvents[0].position.isOverlapping).toBe(true);
+    expect(adjustedEvents[0].position.horizontalOrder).toBe(1);
+    expect(adjustedEvents[1].position.isOverlapping).toBe(true);
+    expect(adjustedEvents[1].position.horizontalOrder).toBe(2);
+    expect(adjustedEvents[2].position.isOverlapping).toBe(true);
+    expect(adjustedEvents[2].position.horizontalOrder).toBe(3);
+
+    // Group 2
+    expect(adjustedEvents[3].position.isOverlapping).toBe(true);
+    expect(adjustedEvents[3].position.horizontalOrder).toBe(1);
+    expect(adjustedEvents[4].position.isOverlapping).toBe(true);
+    expect(adjustedEvents[4].position.horizontalOrder).toBe(2);
+  });
+
+  it("Orders alphabetically if start and end times are the same", () => {
+    const eventA = assembleGridEvent({
+      _id: "A",
+      title: "A",
+      startDate: time("07:30"),
+      endDate: time("08:00"),
+    });
+    const eventB = assembleGridEvent({
+      _id: "B",
+      title: "B",
+      startDate: time("07:30"),
+      endDate: time("08:00"),
+    });
+
+    const adjustedEvents = adjustOverlappingEvents([eventA, eventB]);
+
+    expect(adjustedEvents[0].title).toBe("A");
+    expect(adjustedEvents[0].position.horizontalOrder).toBe(1);
+    expect(adjustedEvents[1].title).toBe("B");
+    expect(adjustedEvents[1].position.horizontalOrder).toBe(2);
+  });
+});