// App.js
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 GAP = 1; // px gap between overlapping meetings
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
const enriched = meetings
.map((m) => ({
...m,
startMin: toMinutes(m.start),
endMin: toMinutes(m.end),
}))
.sort((a, b) => a.startMin - b.startMin);
// Sweep-line to assign columns and cluster meetings
const clusters = [];
let active = [];
let currentCluster = [];
const assignColumn = (event) => {
const used = new Set(active.map((e) => e.col));
let c = 0;
while (used.has(c)) c++;
event.col = c;
};
const closeClusterIfNeeded = (nextStartMin) => {
// If no active items, cluster ends
if (active.length === 0 && currentCluster.length > 0) {
const maxCol = Math.max(...currentCluster.map((e) => e.col));
const totalCols = maxCol + 1;
// finalize layout metrics
currentCluster.forEach((e) => {
const top = (e.startMin - startOfDayMin) * minuteHeight;
const height = Math.max((e.endMin - e.startMin) * minuteHeight, 1);
// width/left with gap
const totalGap = GAP * (totalCols - 1);
const colWidthPct = (100 - totalGap) / totalCols;
const left = e.col * (colWidthPct + GAP);
e.style = {
top,
height,
left: `${left}%`,
width: `${colWidthPct}%`,
};
});
clusters.push(currentCluster);
currentCluster = [];
}
};
for (const ev of enriched) {
// Remove finished from active
active = active.filter((a) => a.endMin > ev.startMin);
// If no active, we might be starting a new cluster
if (active.length === 0) {
// finalize previous cluster
closeClusterIfNeeded(ev.startMin);
}
assignColumn(ev);
active.push(ev);
currentCluster.push(ev);
}
// Close the last cluster
active = [];
closeClusterIfNeeded(Infinity);
// Flatten
const laidOut = clusters.flat();
// Fallback style for single, non-overlapping meetings (in case no clusters formed)
laidOut.forEach((e) => {
if (!e.style) {
const top = (e.startMin - startOfDayMin) * minuteHeight;
const height = Math.max((e.endMin - e.startMin) * minuteHeight, 1);
e.style = { top, height, left: "0%", width: "100%" };
}
});
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,
borderTop: i === 0 ? "none" : undefined,
}}
>
{String(h).padStart(2, "0")}:00
</div>
))}
</div>
<div className="grid-column" style={{ height: timelineHeight }}>
{hours.map((h, i) => (
<div
key={h}
className="grid-hour"
style={{
height: HOUR_HEIGHT,
borderTop: i === 0 ? "none" : undefined,
}}
/>
))}
{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,
}}
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>
);
}
/* styles.css */
.App {
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;
flex: 1;
background: #fff;
}
.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.
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)