import "./styles.css";
import { useMemo } from "react";
const HOUR_HEIGHT = 60; // px per hour
const DAY_START_HOUR = 8;
const DAY_END_HOUR = 18;
const meetingsMock = [
{
id: 1,
title: "Daily Standup",
start: "09:15",
end: "09:45",
color: "#4CAF50",
},
{
id: 2,
title: "Design Review",
start: "10:00",
end: "11:30",
color: "#0EE9F4",
},
{ id: 3, title: "1:1", start: "13:00", end: "13:30", color: "#FF9800" },
{
id: 4,
title: "Project Sync",
start: "10:30",
end: "11:00",
color: "#E91E63",
}, // overlaps with #2
{
id: 5,
title: "Customer Call",
start: "11:00",
end: "12:00",
color: "#9C27B0",
}, // overlaps with #2
{
id: 6,
title: "Lunch & Learn",
start: "12:30",
end: "13:30",
color: "#607D8B",
},
];
function toMinutes(hhmm) {
const [h, m] = hhmm.split(":").map(Number);
return h * 60 + m;
}
function buildLayout(meetings) {
const startOfDayMin = DAY_START_HOUR * 60;
const minuteHeight = HOUR_HEIGHT / 60;
// Enrich with numeric times and sort by start time
const enriched = meetings
.map((m) => ({
...m,
startMin: toMinutes(m.start),
endMin: toMinutes(m.end),
}))
.sort((a, b) => a.startMin - b.startMin);
// Assign positions for partial overlap (approx 50% offset for overlapping events)
const laidOut = [];
for (const event of enriched) {
// Find active (overlapping) events at the start time of the current event
const active = laidOut.filter(
(e) => e.startMin <= event.startMin && e.endMin > event.startMin
);
// Assign a level (similar to column) for horizontal offset
const usedLevels = new Set(active.map((e) => e.level));
let level = 0;
while (usedLevels.has(level)) level++;
event.level = level;
// Set z-index for stacking if needed
event.zIndex = level + 1;
laidOut.push(event);
}
// Calculate styles with partial overlap
laidOut.forEach((event) => {
// Find all events that overlap with this one to determine max overlapping count
const overlapping = laidOut.filter(
(e) =>
(e.startMin <= event.startMin && e.endMin > event.startMin) ||
(event.startMin <= e.startMin && event.endMin > e.startMin)
);
const maxLevel = Math.max(...overlapping.map((e) => e.level));
const overlapCount = maxLevel + 1;
// Calculate style with top, height, and partial horizontal overlap
const top = (event.startMin - startOfDayMin) * minuteHeight;
const height = Math.max((event.endMin - event.startMin) * minuteHeight, 1);
// Width and left for ~50% overlap effect
// Base width is reduced based on overlap count, offset by ~50% per level
const baseWidth = overlapCount > 1 ? 80 / overlapCount : 100; // Shrink width if overlapping
const offsetPerLevel = baseWidth * 0.5; // Approx 50% overlap
const left = event.level * offsetPerLevel;
event.style = {
top: `${top}px`,
height: `${height}px`,
left: `${left}%`,
width: `${baseWidth}%`,
zIndex: event.zIndex,
};
});
return laidOut;
}
export default function App() {
const hours = useMemo(() => {
const arr = [];
for (let h = DAY_START_HOUR; h <= DAY_END_HOUR; h++) arr.push(h);
return arr;
}, []);
const meetings = useMemo(() => buildLayout(meetingsMock), []);
const totalHours = DAY_END_HOUR - DAY_START_HOUR;
const timelineHeight = totalHours * HOUR_HEIGHT;
return (
<div className="App">
<h1>Day Timeline</h1>
<div className="calendar">
<div className="time-column">
{hours.map((h, i) => (
<div
key={h}
className="time-label"
style={{
height: HOUR_HEIGHT,
}}
>
{h}:00
</div>
))}
</div>
<div className="grid-column" style={{ height: timelineHeight }}>
{hours.map((h, i) => (
<div
key={h}
className="grid-hour"
style={{
height: HOUR_HEIGHT,
}}
/>
))}
{meetings.map((m) => (
<div
key={m.id}
className="event"
style={{
top: m.style.top,
height: m.style.height,
left: m.style.left,
width: m.style.width,
background: m.color,
zIndex: m.style.zIndex,
}}
title={`${m.title} (${m.start} - ${m.end})`}
>
<div className="event-title">{m.title}</div>
<div className="event-time">
{m.start} - {m.end}
</div>
</div>
))}
</div>
</div>
</div>
);
}
.p {
font-family: sans-serif;
padding: 16px;
color: #1f1f1f;
}
h1 {
margin-bottom: 12px;
font-size: 18px;
}
.calendar {
display: flex;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
.time-column {
width: 72px;
background: #fafafa;
border-right: 1px solid #e0e0e0;
}
.time-label {
height: 60px;
box-sizing: border-box;
padding: 4px 8px;
font-size: 12px;
color: #666;
display: flex;
align-items: flex-start;
justify-content: flex-end;
}
.grid-column {
position: relative; /* Ensure absolute positioning of events works */
flex: 1;
background: #fff;
min-width: 200px;
}
.grid-hour {
height: 60px;
border-top: 1px solid #eee;
box-sizing: border-box;
}
.event {
position: absolute;
box-sizing: border-box;
padding: 6px 8px;
border-radius: 6px;
color: white;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.event-title {
font-size: 12px;
font-weight: 600;
line-height: 1.2;
margin-bottom: 2px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.event-time {
font-size: 11px;
opacity: 0.85;
}
Here’s what buildLayout does, step by step, with a concrete example.
Purpose
- Convert meeting times (e.g., "10:30") into positions and sizes on a single-day timeline.
- Detect overlapping meetings, group them into clusters, and assign columns so overlaps are shown side-by-side.
- Compute absolute top/height (vertical) and left/width (horizontal) for each meeting.
Key variables
- startOfDayMin: minutes from midnight where the view starts (e.g., 8:00 → 480).
- minuteHeight: how many pixels per minute (HOUR_HEIGHT / 60). If HOUR_HEIGHT = 60, 1 minute = 1 px.
- GAP: horizontal space between overlapping columns (treated as a percentage in this code).
High-level flow
1) Normalize and sort meetings
- For each meeting, compute startMin and endMin (minutes after midnight).
- Sort by startMin ascending.
2) Sweep-line clustering and column assignment
- Walk meetings in start-time order.
- Maintain:
- active: meetings currently ongoing (not yet ended at the next meeting’s start).
- currentCluster: all meetings that overlap directly or via a chain; we’ll layout these together.
- For each new meeting ev:
- Remove from active any meeting that has ended (endMin <= ev.startMin).
- If active becomes empty, we close the previous cluster (if any).
- Assign ev a column index not used by active items.
- Add ev to active and to currentCluster.
3) Close cluster and compute styles
- When a cluster ends (active is empty), compute for each meeting in that cluster:
- totalCols: number of side-by-side columns needed = max assigned column + 1.
- Vertical:
- top = (startMin - startOfDayMin) * minuteHeight
- height = max((endMin - startMin) * minuteHeight, 1)
- Horizontal:
- totalGap = GAP * (totalCols - 1)
- colWidthPct = (100 - totalGap) / totalCols
- left = colIndex * (colWidthPct + GAP)
- width = colWidthPct
- This ensures overlapping meetings share the horizontal space, separated by GAP.
- If something slipped through without style (edge case), fallback to full width.
// Function to layout meetings in a calendar with partial overlap
FUNCTION buildLayout(meetings):
// Initialize constants for time calculations
startOfDayMinutes = DAY_START_HOUR * 60
minuteHeight = HOUR_HEIGHT / 60 // Height per minute for positioning
// Step 1: Convert meeting start/end times to minutes and sort by start time
enrichedMeetings = []
FOR each meeting in meetings:
meeting.startMin = convertToMinutes(meeting.start) // e.g., "09:15" -> 555 minutes
meeting.endMin = convertToMinutes(meeting.end)
ADD meeting to enrichedMeetings
SORT enrichedMeetings by startMin
// Step 2: Assign a "level" to each meeting for handling overlaps
laidOutMeetings = []
FOR each event in enrichedMeetings:
// Find events that are active (overlapping) when this event starts
activeEvents = FILTER laidOutMeetings WHERE event.startMin is between active.startMin and active.endMin
// Assign the smallest available level (like a column) not used by active events
usedLevels = SET of levels from activeEvents
level = 0
WHILE level is in usedLevels:
INCREMENT level
event.level = level
event.zIndex = level + 1 // For visual stacking if needed
ADD event to laidOutMeetings
// Step 3: Calculate visual styles (position and size) for each meeting
FOR each event in laidOutMeetings:
// Find all events that overlap with this event (before or after)
overlappingEvents = FILTER laidOutMeetings WHERE time ranges overlap with event
maxLevel = MAXIMUM level among overlappingEvents
overlapCount = maxLevel + 1 // Total number of overlapping slots
// Calculate vertical position and height based on time
top = (event.startMin - startOfDayMinutes) * minuteHeight // Position from top
height = (event.endMin - event.startMin) * minuteHeight OR minimum 1px
// Calculate horizontal position and width for ~50% overlap
IF overlapCount > 1:
baseWidth = 80 / overlapCount // Shrink width if overlapping
ELSE:
baseWidth = 100 // Full width if no overlap
offsetPerLevel = baseWidth * 0.5 // Offset by ~50% of width per level
left = event.level * offsetPerLevel // Horizontal position based on level
// Assign style properties for rendering
event.style = {
top: top in pixels,
height: height in pixels,
left: left as percentage,
width: baseWidth as percentage,
zIndex: event.zIndex
}
RETURN laidOutMeetings
function buildLayout(meetings) {
const startOfDayMin = DAY_START_HOUR * 60; // e.g., 8 * 60 = 480 minutes (8:00 AM)
const minuteHeight = HOUR_HEIGHT / 60; // e.g., 60px/hour / 60 = 1px per minute
// Step 1: Convert times to minutes and sort by start time
// This ensures we process events in chronological order
const enriched = meetings
.map((m) => ({
...m,
startMin: toMinutes(m.start), // Convert "HH:MM" to minutes
endMin: toMinutes(m.end),
}))
.sort((a, b) => a.startMin - b.startMin);
// Step 2: Assign levels for overlap handling
const laidOut = [];
for (const event of enriched) {
// Find events that are still active (overlapping) when this event starts
const active = laidOut.filter(
(e) => e.startMin <= event.startMin && e.endMin > event.startMin
);
// Assign the smallest unused level (like a column) among active events
const usedLevels = new Set(active.map((e) => e.level));
let level = 0;
while (usedLevels.has(level)) level++;
event.level = level;
// Set z-index based on level for visual stacking (higher level = on top)
event.zIndex = level + 1;
laidOut.push(event);
}
// Step 3: Calculate styles for rendering with partial overlap
laidOut.forEach((event) => {
// Find all events that overlap with this event (to determine total overlap count)
const overlapping = laidOut.filter(
(e) =>
(e.startMin <= event.startMin && e.endMin > event.startMin) ||
(event.startMin <= e.startMin && event.endMin > e.startMin)
);
const maxLevel = Math.max(...overlapping.map((e) => e.level));
const overlapCount = maxLevel + 1; // Total number of overlapping slots
// Calculate vertical position (top) and height based on time
const top = (event.startMin - startOfDayMin) * minuteHeight;
const height = Math.max((event.endMin - event.startMin) * minuteHeight, 1);
// Calculate horizontal position and width for ~50% overlap
// If overlapping, shrink width; if not, use full width
const baseWidth = overlapCount > 1 ? 80 / overlapCount : 100;
const offsetPerLevel = baseWidth * 0.5; // Offset by ~50% of width per level
const left = event.level * offsetPerLevel; // Position based on level
// Assign style object for CSS properties
event.style = {
top: `${top}px`,
height: `${height}px`,
left: `${left}%`,
width: `${baseWidth}%`,
zIndex: event.zIndex,
};
});
return laidOut;
}
Concrete example
Assume:
- DAY_START_HOUR = 8 (startOfDayMin = 480)
- HOUR_HEIGHT = 60 (minuteHeight = 1 px/min)
- GAP = 6 (interpreted as 6% horizontal gap) Meetings: A: 10:00–11:30 B: 10:30–11:00 C: 11:00–12:00 D: 13:00–13:30
Convert times to minutes:
- A: start 600, end 690
- B: start 630, end 660
- C: start 660, end 720
- D: start 780, end 810
Walk through:
- Start with active = [], currentCluster = [].
1) A (600–690)
- active after pruning: []
- active empty → close previous cluster (none yet)
- assignColumn: active uses {}, so A gets col 0
- active = [A], currentCluster = [A]
2) B (630–660)
- prune: A.end 690 > 630, keep A
- active not empty → don’t close cluster
- assignColumn: used = {0} → B gets col 1
- active = [A, B], currentCluster = [A, B]
3) C (660–720)
- prune: A.end 690 > 660 keep; B.end 660 > 660 is false → remove B
- active = [A]; not empty → don’t close cluster
- assignColumn: used = {0} → C gets col 1
- active = [A, C], currentCluster = [A, B, C]
4) D (780–810)
- prune for D’s start (780): A.end 690 > 780 false (remove), C.end 720 > 780 false (remove)
- active becomes [] → close cluster [A, B, C]
- totalCols = max col + 1 = 1 + 1 = 2
- Horizontal metrics for this cluster:
- totalGap = 6 * (2 - 1) = 6
- colWidthPct = (100 - 6) / 2 = 47%
- col 0: left = 0 * (47 + 6) = 0%
- col 1: left = 1 * (47 + 6) = 53%
- Vertical metrics:
- A: top = (600 - 480) * 1 = 120 px; height = (690 - 600) = 90 px left 0%, width 47%
- B: top = (630 - 480) = 150 px; height = (660 - 630) = 30 px left 53%, width 47%
- C: top = (660 - 480) = 180 px; height = (720 - 660) = 60 px left 53%, width 47%
- Now handle D:
- active empty → assignColumn for D with used = {} → col 0
- active = [D], currentCluster = [D]
End of input:
- Force close last cluster [D]:
- totalCols = 1 → totalGap = 0, colWidthPct = 100%
- D vertical: top = (780 - 480) = 300 px; height = (810 - 780) = 30 px
- D horizontal: left 0%, width 100%
Resulting layout:
- A: top 120px, height 90px, left 0%, width 47%
- B: top 150px, height 30px, left 53%, width 47%
- C: top 180px, height 60px, left 53%, width 47%
- D: top 300px, height 30px, left 0%, width 100%
Why this works
- Overlapping meetings are grouped in a single cluster so they can be arranged side-by-side.
- Columns are assigned greedily to the first available slot, minimizing total columns.
- The cluster width is split evenly among columns (minus gaps), so all overlapping items fit without overlapping visually.
- Non-overlapping meetings get full width.
Note on GAP
- In this code, GAP is treated as a percentage in horizontal calculations (left/width use %). If you prefer pixel gaps, compute horizontal positions in pixels based on the container width, or use CSS calc() to mix units carefully.
Top comments (0)