Skip to content

Commit 29d4527

Browse files
committed
feat(scheduled-tasks): Google-Calendar-style side-by-side overlap layout
Tasks whose pills would collide now split the column into side-by-side lanes (like Google Calendar) instead of stacking on top of each other; tasks that don't overlap keep the full width. Adds a pure layoutColumn lane-assignment helper (interval clustering + greedy lane packing) with tests.
1 parent 4021af7 commit 29d4527

3 files changed

Lines changed: 105 additions & 4 deletions

File tree

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/time-grid.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import { cn } from '@/lib/core/utils/cn'
77
import { CalendarEventChip } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip'
88
import {
99
type CalendarDayCell,
10+
EVENT_CHIP_HEIGHT,
1011
formatHourLabel,
1112
formatSlotTime,
13+
layoutColumn,
1214
TIME_SLOT_HEIGHT,
1315
timeToOffset,
1416
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
@@ -106,24 +108,30 @@ function DayEvents({
106108
onSelectTask: (task: ScheduledTask) => void
107109
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
108110
}) {
111+
const placed = layoutColumn(events, EVENT_CHIP_HEIGHT)
109112
return (
110113
<div
111114
className={cn(
112115
'pointer-events-none absolute inset-y-0 left-0.5 z-10',
113116
isLastColumn ? 'right-6' : 'right-0.5'
114117
)}
115118
>
116-
{events.map((event) => (
119+
{placed.map(({ item: event, topPx, lane, lanes }) => (
117120
<div
118121
key={event.id}
119-
style={{ top: timeToOffset(event.start) }}
120-
className='pointer-events-auto absolute inset-x-0'
122+
style={{
123+
top: topPx,
124+
height: EVENT_CHIP_HEIGHT,
125+
left: `${(lane / lanes) * 100}%`,
126+
width: `${(1 / lanes) * 100}%`,
127+
}}
128+
className='pointer-events-auto absolute pr-0.5'
121129
>
122130
<CalendarEventChip
123131
event={event}
124132
onSelect={onSelectTask}
125133
onContextMenu={onTaskContextMenu}
126-
className='w-full'
134+
className='h-full w-full'
127135
/>
128136
</div>
129137
))}

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { describe, expect, it } from 'vitest'
55
import {
66
advanceAnchor,
77
buildCalendarGrid,
8+
EVENT_CHIP_HEIGHT,
89
formatHourLabel,
910
formatScopeLabel,
1011
formatSlotTime,
1112
HOURS,
13+
layoutColumn,
1214
TIME_SLOT_HEIGHT,
1315
timeToOffset,
1416
WEEKDAY_LABELS,
@@ -94,3 +96,39 @@ describe('timeToOffset', () => {
9496
expect(timeToOffset(new Date(2026, 5, 10, 23, 0))).toBe(23 * TIME_SLOT_HEIGHT)
9597
})
9698
})
99+
100+
describe('layoutColumn', () => {
101+
const at = (h: number, m: number) => ({ start: new Date(2026, 5, 15, h, m) })
102+
103+
it('keeps non-overlapping events full width in a single lane', () => {
104+
const placed = layoutColumn([at(9, 0), at(11, 0)], EVENT_CHIP_HEIGHT)
105+
expect(placed.map((p) => ({ lane: p.lane, lanes: p.lanes }))).toEqual([
106+
{ lane: 0, lanes: 1 },
107+
{ lane: 0, lanes: 1 },
108+
])
109+
})
110+
111+
it('splits overlapping events into side-by-side lanes', () => {
112+
// 9:00 and 9:10 are ~8px apart — within the 22px pill height, so they overlap.
113+
const placed = layoutColumn([at(9, 0), at(9, 10)], EVENT_CHIP_HEIGHT)
114+
expect(placed.map((p) => ({ lane: p.lane, lanes: p.lanes }))).toEqual([
115+
{ lane: 0, lanes: 2 },
116+
{ lane: 1, lanes: 2 },
117+
])
118+
})
119+
120+
it('reuses a freed lane after the overlap clears and resets the cluster', () => {
121+
const placed = layoutColumn([at(9, 0), at(9, 10), at(12, 0)], EVENT_CHIP_HEIGHT)
122+
expect(placed.map((p) => ({ lane: p.lane, lanes: p.lanes }))).toEqual([
123+
{ lane: 0, lanes: 2 },
124+
{ lane: 1, lanes: 2 },
125+
{ lane: 0, lanes: 1 },
126+
])
127+
})
128+
129+
it('sorts by start time before assigning lanes', () => {
130+
const placed = layoutColumn([at(9, 10), at(9, 0)], EVENT_CHIP_HEIGHT)
131+
expect(placed[0].item).toEqual(at(9, 0))
132+
expect(placed[1].item).toEqual(at(9, 10))
133+
})
134+
})

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export const HOURS: number[] = Array.from({ length: 24 }, (_, hour) => hour)
5454
/** Fixed pixel height of one hour row in the time grid. */
5555
export const TIME_SLOT_HEIGHT = 48
5656

57+
/** Rendered height of a task pill in the time grid, used for overlap detection. */
58+
export const EVENT_CHIP_HEIGHT = 22
59+
5760
const BASE_WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
5861

5962
/** Weekday header labels rotated to honor {@link WEEK_STARTS_ON}. */
@@ -170,3 +173,55 @@ export function timeToOffset(date: Date): number {
170173
export function formatSlotTime(hour: number): string {
171174
return `${hour.toString().padStart(2, '0')}:00`
172175
}
176+
177+
/** A time-grid item placed at its minute, with its column slot within an overlap cluster. */
178+
export interface PlacedEvent<T> {
179+
item: T
180+
/** Pixel offset from the top of the day column. */
181+
topPx: number
182+
/** 0-based column index within the overlap cluster. */
183+
lane: number
184+
/** Total columns the overlap cluster spans (1 when nothing overlaps). */
185+
lanes: number
186+
}
187+
188+
/**
189+
* Google-Calendar-style lane assignment for events sharing a day column. Items
190+
* whose pill rectangles (`[topPx, topPx + chipHeight]`) intersect form a cluster
191+
* and split the width into side-by-side lanes; non-overlapping items keep the
192+
* full width. Pure: positions come from {@link timeToOffset}.
193+
*/
194+
export function layoutColumn<T extends { start: Date }>(
195+
items: T[],
196+
chipHeight: number
197+
): PlacedEvent<T>[] {
198+
const sorted = [...items].sort((a, b) => a.start.getTime() - b.start.getTime())
199+
const placed: PlacedEvent<T>[] = []
200+
let cluster: PlacedEvent<T>[] = []
201+
let laneBottoms: number[] = []
202+
203+
const closeCluster = () => {
204+
for (const entry of cluster) entry.lanes = laneBottoms.length
205+
cluster = []
206+
laneBottoms = []
207+
}
208+
209+
for (const item of sorted) {
210+
const topPx = timeToOffset(item.start)
211+
if (laneBottoms.length > 0 && laneBottoms.every((bottom) => topPx >= bottom)) {
212+
closeCluster()
213+
}
214+
let lane = laneBottoms.findIndex((bottom) => topPx >= bottom)
215+
if (lane === -1) {
216+
lane = laneBottoms.length
217+
laneBottoms.push(topPx + chipHeight)
218+
} else {
219+
laneBottoms[lane] = topPx + chipHeight
220+
}
221+
const entry: PlacedEvent<T> = { item, topPx, lane, lanes: 1 }
222+
cluster.push(entry)
223+
placed.push(entry)
224+
}
225+
closeCluster()
226+
return placed
227+
}

0 commit comments

Comments
 (0)