DEV Community

Cover image for React Coding Challenge : Meeting Calendar
ZeeshanAli-0704
ZeeshanAli-0704

Posted on

React Coding Challenge : Meeting Calendar

// 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>
  );
}

Enter fullscreen mode Exit fullscreen mode
/* 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;
}

Enter fullscreen mode Exit fullscreen mode

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)