Back to System Design
System Design
hard
senior

How would you design a calendar UI similar to Google Calendar?

Time-based grid (day/week/month). Events stored normalized with start/end + recurrence rule (RRULE). Render by computing visible occurrences for the current view. Handle overlap layout, drag-to-create/move, and timezones.

11 min read·~60 min to think through

A calendar app is one of the densest "design a UI" prompts: dozens of features, but only 5 hard problems underneath. The senior answer focuses on those: data model with recurrence, view rendering, overlap layout, drag interactions, and timezones.

Clarifying questions (always).

  • Day / Week / Month views, or all three?
  • Recurring events?
  • Multi-day events?
  • Multi-user / shared calendars?
  • Real-time collaboration (someone else moves an event)?
  • Timezone-aware (events in user's TZ vs creator's TZ)?
  • Offline?

Scope this answer to: Day/Week/Month, recurring + multi-day, single user, timezone-aware.

Data model.

ts
type EventInput = {
  id: string;
  title: string;
  start: string;        // ISO with timezone, e.g., "2026-05-11T14:00:00-07:00"
  end: string;
  rrule?: string;       // RFC 5545 RRULE: "FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20261231T000000Z"
  exceptions?: string[]; // dates the recurrence skips
  calendarId: string;
};

type EventOccurrence = {
  eventId: string;     // points back to EventInput
  start: Date;
  end: Date;
  isException?: boolean; // user moved/edited this single occurrence
};

The split matters: the EventInput is the master record (one row per logical event); EventOccurrence is what's visible in the current view. For a weekly recurring event over 2 years, you store one input but render N occurrences.

Recurrence — use a library. Implementing RFC 5545 RRULE from scratch is a multi-month project. Use rrule.js to expand a recurrence rule into occurrences within a window. Store exceptions (EXDATE) and overrides separately.

Render = expand current view's window.

ts
function visibleOccurrences(events: EventInput[], from: Date, to: Date): EventOccurrence[] {
  const out: EventOccurrence[] = [];
  for (const e of events) {
    if (e.rrule) {
      const rule = RRule.fromString(e.rrule);
      for (const start of rule.between(from, to, true)) {
        if (e.exceptions?.includes(start.toISOString())) continue;
        const duration = +new Date(e.end) - +new Date(e.start);
        out.push({ eventId: e.id, start, end: new Date(+start + duration) });
      }
    } else if (overlaps(new Date(e.start), new Date(e.end), from, to)) {
      out.push({ eventId: e.id, start: new Date(e.start), end: new Date(e.end) });
    }
  }
  return out;
}

Memoize by view window so view switches don't recompute.

Overlap layout (the tricky one). Two events at 2pm–3pm both want the same slot. Algorithm:

  1. Sort occurrences by start time.
  2. Walk through; group events that overlap into a "cluster."
  3. Within a cluster, assign each event a column (greedy: pick the leftmost column not in use at this event's start).
  4. Render each event at left: column / clusterCols * 100%, width 100% / clusterCols.

For a clean look, also support partial-width overlaps (Google Calendar style — adjacent events get full width if they only overlap by 5 minutes).

Drag interactions.

  • Drag to create: mousedown on an empty cell → drag to set duration → mouseup confirms. Track startY, compute time from pixel offset (y / pxPerHour).
  • Drag to move: event card is draggable; drop snaps to 15-minute increments.
  • Drag edge to resize: top/bottom of card resizes start/end.

Use pointer events (pointerdown/pointermove/pointerup) for cross-device support and capture so the drag continues even if pointer leaves the cell.

Timezones — the senior pitfall. Store events in UTC (or with offset) on the server. Display in the user's TZ. Use luxon or date-fns-tz — never raw new Date(), which interprets in the local TZ. Recurring events are tricky: "every Monday at 9am" in London DST-shifts twice a year; respect the TZ on the rule, not the absolute UTC.

Editing recurring events. UX prompt: "Edit this event only / This and following / All events." Stored as: single override (push into exceptions, create a one-off event), split the recurrence into two rules, or modify the master.

Real-time / multi-user. WebSocket subscription per calendar; events arrive as patches. Use the same seq + resume pattern as other real-time features. Conflict resolution: last-write-wins on event content; for simultaneous moves, server is the arbiter.

Performance.

  • Window the visible range — don't expand all recurrences ever, only for the current view.
  • Memoize per view; switching weeks should hit cache for re-visits.
  • Virtualize the month view's day cells if rendering > 6 weeks at once.
  • Avoid re-rendering all events on a single drag — only the dragged event and its overlap cluster need to update.

Accessibility.

  • Keyboard nav: arrows move focus across cells, Enter creates event, Tab into an event opens the editor.
  • ARIA: role="grid", gridcell, aria-label describing date and events.
  • Don't rely on color alone for calendar distinction — patterns or labels.

Tests.

  • Recurrence expansion across DST boundaries.
  • Overlap layout with 1, 2, 5, 10 concurrent events.
  • Edit-this-and-following splitting the rule correctly.
  • Timezone display when user's TZ differs from event TZ.

Code

ts
function layoutCluster(events: EventOccurrence[]) {
  const sorted = [...events].sort((a, b) => +a.start - +b.start);
  const columns: EventOccurrence[][] = [];
  const placed: { ev: EventOccurrence; col: number }[] = [];

  for (const ev of sorted) {
    let col = columns.findIndex(c => +c[c.length - 1].end <= +ev.start);
    if (col === -1) { col = columns.length; columns.push([]); }
    columns[col].push(ev);
    placed.push({ ev, col });
  }
  const total = columns.length;
  return placed.map(({ ev, col }) => ({ ev, leftPct: (col / total) * 100, widthPct: 100 / total }));
}
Greedy overlap-column assignment

Follow-up questions

  • How would you store and expand recurring events?
  • Why do recurring events need the user's timezone, not just UTC?
  • How would you handle real-time updates from another collaborator?
  • What's the algorithm for laying out overlapping events?

Common mistakes

  • Storing recurrence as exploded rows — millions of rows for forever-rules.
  • Using new Date() everywhere — TZ bugs on DST.
  • Re-expanding all events on every render instead of memoizing per view.
  • Storing layout positions in state instead of computing from clusters.

Performance considerations

  • Window expansion to current view; cache by [calendarId, viewKey].
  • Use CSS Grid for the day/week grid; absolute-position events on top.
  • Memoize cluster layouts; only recompute when events in that cluster change.

Edge cases

  • Event spanning midnight into the next day — render as two segments per day.
  • Recurring event ending mid-occurrence-window — clip last instance.
  • DST 'spring forward' on an event at 2:30am — RRULE library handles, but verify.

Real-world examples

  • Google Calendar, Notion Calendar (Cron), Fantastical, Outlook Calendar — all use RRULE + cluster overlap layout.

Senior engineer discussion

Senior signal: master + occurrence split, RRULE library reach, overlap layout algorithm, timezone correctness, and edit-recurring UX.

Related questions