DEV Community

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

Posted on • Edited on

React Coding Challenge : Meeting Calendar

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

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

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

Enter fullscreen mode Exit fullscreen mode

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)