Skip to content
Advanced100 min build target7 min guide

Calendar interface

A month-view calendar with event creation, multi-day events, and correct date-boundary handling.

Calendar interface interface reference

HOW TO USE THIS CHALLENGE

  1. 1. Read the briefClarify decisions before coding.
  2. 2. Build from memoryUse the 100-minute target.
  3. 3. Study the guideCompare architecture, tests, and trade-offs.

REQUIREMENTS

  • Render a month grid with correct day-of-week alignment and leading/trailing days from adjacent months.
  • Create, edit, and delete events by clicking/dragging on a day cell.
  • Render multi-day events as a single spanning bar across the days they cover, not a separate entry per day.
  • Navigate between months (and jump to 'today').
  • Handle overlapping events on the same day with a legible stacked layout.

EDGE CASES

  • Month/year boundaries (Dec → Jan) and leap years must compute the grid correctly.
  • Time zone handling: an event's displayed day must not shift due to UTC/local conversion bugs, especially near midnight.
  • A multi-day event that starts before the visible month or ends after it should still render its visible portion correctly, clipped at the grid edge.
  • Many overlapping events in one day shouldn't overflow the cell unreadably — cap visible rows with a '+N more' affordance.

ACCESSIBILITY

  • The grid should be navigable with arrow keys between days, with roving tabindex so Tab doesn't stop on every single day cell.
  • Each day cell needs an accessible name including the full date (e.g. 'Tuesday, March 4, 3 events'), not just the day number.
  • The event creation/edit form must be reachable via keyboard, not only via mouse drag on the grid.

SUGGESTED APPROACH

  • Compute the grid purely from a given month/year: first weekday offset, days in month, and leading/trailing days from adjacent months, all derived, not stored.
  • Normalize all event dates to a consistent representation (e.g. UTC midnight or a date-only string) at the data boundary to avoid time-zone drift in day placement.
  • Represent a multi-day event as start/end dates and compute, per visible week row, which columns it spans, rendering one bar per row rather than one cell per day.
  • Use a date library (or a small set of well-tested pure date-math helpers) rather than hand-rolling arithmetic with the native Date object's mutable, zero-indexed-month API.

EVALUATION RUBRIC

  • Month grid renders correctly across boundary cases (month/year rollover, leap years).
  • Multi-day events render as a single spanning bar, correctly clipped at week/month edges.
  • No timezone-induced off-by-one-day bugs in event placement.
  • Grid supports keyboard navigation with sensible roving focus.

01Understand the product before coding

Learning goals

  • Render a month grid with correct day-of-week alignment and leading/trailing days from adjacent months.
  • Create, edit, and delete events by clicking/dragging on a day cell.
  • Render multi-day events as a single spanning bar across the days they cover, not a separate entry per day.
  • Navigate between months (and jump to 'today').

Decisions to state aloud

  • Month/year boundaries (Dec → Jan) and leap years must compute the grid correctly.
  • Time zone handling: an event's displayed day must not shift due to UTC/local conversion bugs, especially near midnight.
  • A multi-day event that starts before the visible month or ends after it should still render its visible portion correctly, clipped at the grid edge.
  • Many overlapping events in one day shouldn't overflow the cell unreadably — cap visible rows with a '+N more' affordance.

02State model and invariants

Represent calendar dates as date-only ISO strings and timed instants separately. Events use exclusive end boundaries and a declared time zone. Visible month, focused date, selected date, and editor state are independent.

TypeScript
type DateKey = `${number}-${number}-${number}`;type CalendarEvent = { id: string; title: string;  startDate: DateKey; endDateExclusive: DateKey;  timeZone?: string; };type CalendarState = { visibleMonth: DateKey; focusedDate: DateKey;  selectedDate: DateKey | null; editorEventId: string | null; };

03Component architecture

  1. 01Calendar owns visible range and date math adapter.
  2. 02MonthGrid renders six or variable weeks with roving focus.
  3. 03WeekRow lays out event segments and overlap lanes.
  4. 04DayCell exposes full-date and event-count names.
  5. 05EventDialog provides keyboard create/edit/delete.

04Reference implementation walkthrough

Step 1

Generate the visible grid purely

Find the locale-configured week start containing the month's first day, then generate complete weeks through the week containing the last day. Adjacent-month cells remain real dates.

TypeScript
function monthGrid(month: PlainDate, weekStartsOn: number) {  const start = startOfWeek(month.with({ day: 1 }), weekStartsOn);  const end = endOfWeek(month.with({ day: month.daysInMonth }), weekStartsOn);  const days: PlainDate[] = [];  for (let day = start; compare(day, end) <= 0; day = day.add({ days: 1 })) days.push(day);  return chunk(days, 7);}

Step 2

Split multi-day events into week segments

Intersect each event's half-open date range with each visible week. The clipped start and end produce grid-column start and span, so one event renders once per crossed week, not once per day.

TypeScript
const segmentStart = maxDate(event.startDate, week.start);const segmentEnd = minDate(event.endDateExclusive, week.endExclusive);if (compare(segmentStart, segmentEnd) >= 0) return null;return {  column: daysBetween(week.start, segmentStart) + 1,  span: daysBetween(segmentStart, segmentEnd)};

Step 3

Assign overlap lanes

Sort segments by start then longer duration, place each into the first lane whose previous segment ends before this begins, and cap visible lanes with a keyboard-operable more-events control.

Step 4

Implement grid navigation

Roving tabindex keeps one day in the Tab sequence. Arrows move by day or week, Home/End move within a week, and Page Up/Down change months while preserving a valid day where possible.

SOLComplete solution, explained simply

Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.

We are building a month calendar that draws the surrounding days to fill complete weeks, shows a multi-day event as one continuous bar that stacks when events overlap, and moves the keyboard focus one day or one week at a time. The one big idea: treat a calendar day as a plain YYYY-MM-DD string (never a timestamp, so no time zone can shift it), use an exclusive end date, and clip each event against each week to get a column and a span.

Calendar.tsx
import { useMemo, useState } from "react";
type DateKey = string; // "YYYY-MM-DD"type CalendarEvent = { id: string; title: string; start: DateKey; endExclusive: DateKey };
// --- date-only helpers: no times, so no time zone can move a day ---const parts = (k: DateKey) => k.split("-").map(Number) as [number, number, number];const addDays = (k: DateKey, n: number): DateKey => {  const [y, m, d] = parts(k);  return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);};const weekday = (k: DateKey) => { const [y, m, d] = parts(k); return new Date(Date.UTC(y, m - 1, d)).getUTCDay(); };const diffDays = (a: DateKey, b: DateKey) => Math.round((Date.parse(b) - Date.parse(a)) / 86_400_000);const startOfWeek = (k: DateKey, weekStartsOn: number) => addDays(k, -((weekday(k) - weekStartsOn + 7) % 7));const todayKey = () => new Date().toISOString().slice(0, 10);const longLabel = (k: DateKey) =>  new Date(k + "T00:00:00Z").toLocaleDateString(undefined, {    weekday: "long", month: "long", day: "numeric", timeZone: "UTC",  });
function buildGrid(month: DateKey, weekStartsOn: number): DateKey[][] {  const [y, m] = parts(month);  const first = month.slice(0, 7) + "-01";  const lastDay = new Date(Date.UTC(y, m, 0)).getUTCDate();  const last = month.slice(0, 7) + "-" + String(lastDay).padStart(2, "0");  const gridStart = startOfWeek(first, weekStartsOn);  const gridEndExclusive = addDays(startOfWeek(last, weekStartsOn), 7);  const weeks: DateKey[][] = [];  for (let d = gridStart; d < gridEndExclusive; d = addDays(d, 7)) {    weeks.push(Array.from({ length: 7 }, (_, i) => addDays(d, i)));  }  return weeks;}
// Clip one event to one week. Returns the grid column (1..7) and how many days it spans, or null.function segmentForWeek(event: CalendarEvent, weekStart: DateKey) {  const weekEndExclusive = addDays(weekStart, 7);  const segStart = event.start > weekStart ? event.start : weekStart;  const segEndExclusive = event.endExclusive < weekEndExclusive ? event.endExclusive : weekEndExclusive;  if (segStart >= segEndExclusive) return null;  return { column: diffDays(weekStart, segStart) + 1, span: diffDays(segStart, segEndExclusive) };}
// Stack overlapping segments: put each in the first lane that is free at its start column.function assignLanes<T extends { column: number; span: number }>(segments: T[]) {  const laneFreeFrom: number[] = [];  return segments    .slice()    .sort((a, b) => a.column - b.column || b.span - a.span)    .map((seg) => {      let lane = laneFreeFrom.findIndex((free) => free <= seg.column);      if (lane === -1) lane = laneFreeFrom.length;      laneFreeFrom[lane] = seg.column + seg.span;      return { ...seg, lane };    });}
export function Calendar({ month, events, weekStartsOn = 0 }: {  month: DateKey; events: CalendarEvent[]; weekStartsOn?: number;}) {  const weeks = useMemo(() => buildGrid(month, weekStartsOn), [month, weekStartsOn]);  const flat = useMemo(() => weeks.flat(), [weeks]);  const [, monthNum] = parts(month);  const [focused, setFocused] = useState<DateKey>(    () => flat.find((d) => parts(d)[1] === monthNum) ?? flat[0],  );
  function move(deltaDays: number) {    const target = addDays(focused, deltaDays);    if (flat.includes(target)) setFocused(target);  }  function onKeyDown(e: React.KeyboardEvent) {    const arrows: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: 7, ArrowUp: -7 };    if (e.key in arrows) { e.preventDefault(); move(arrows[e.key]); }    else if (e.key === "Home") { e.preventDefault(); setFocused(startOfWeek(focused, weekStartsOn)); }    else if (e.key === "End") { e.preventDefault(); setFocused(addDays(startOfWeek(focused, weekStartsOn), 6)); }  }
  return (    <div role="grid" aria-label={"Calendar for " + month.slice(0, 7)} onKeyDown={onKeyDown}>      {weeks.map((week) => {        const segs = assignLanes(          events            .map((ev) => {              const s = segmentForWeek(ev, week[0]);              return s ? { ...s, ev } : null;            })            .filter((s): s is { column: number; span: number; ev: CalendarEvent } => s !== null),        );        return (          <div            key={week[0]}            role="row"            style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)" }}          >            {week.map((day) => (              <div                key={day}                role="gridcell"                tabIndex={day === focused ? 0 : -1}                aria-current={day === todayKey() ? "date" : undefined}                aria-label={longLabel(day)}                onClick={() => setFocused(day)}                style={{ gridRow: 1, opacity: parts(day)[1] === monthNum ? 1 : 0.4 }}              >                {parts(day)[2]}              </div>            ))}            {segs.map((s) => (              <div                key={s.ev.id + week[0]}                style={{ gridColumn: s.column + " / span " + s.span, gridRow: s.lane + 2 }}              >                {s.ev.title}              </div>            ))}          </div>        );      })}    </div>  );}

How each part works

A day is a string, not a timestamp

A calendar day like '2026-03-05' is a label, not a moment in time. If you store it as a Date it becomes midnight in some time zone, and formatting it elsewhere can show the 4th or the 6th. Keeping it as 'YYYY-MM-DD' and only using UTC math (Date.UTC) means the day never drifts.

Events use an exclusive end date

endExclusive is the day after the event's last day. A one-day event on the 5th has start '...-05' and endExclusive '...-06'. Half-open ranges make every calculation — length, does-it-cross-this-week, do-two-events-overlap — a simple comparison with no 'add one day at the end' special case.

buildGrid fills complete weeks

Take the first of the month, walk back to the start of its week; take the last of the month, walk forward to the end of its week; then slice that range into rows of seven. The leading and trailing cells are real dates from the neighboring months, just dimmed.

segmentForWeek clips an event to one week

For each week, the visible piece of an event starts at the later of (event start, week start) and ends at the earlier of (event end, week end). If that piece is empty, the event does not appear that week. Otherwise it gives a starting column (1 to 7) and a day span, so a 10-day event renders as one bar in each week it touches, not ten separate day marks.

assignLanes stacks overlapping bars

Sort segments by start column (longer first on ties), then drop each into the first lane whose previous bar has already ended before this one starts. laneFreeFrom[lane] tracks where each lane becomes free again. Overlapping events end up on different rows so they do not sit on top of each other.

Roving focus over the flat day list

Only the focused day has tabIndex 0, so Tab jumps past the whole calendar. Arrow keys add or subtract 1 or 7 days and move focus if the result is still in the visible grid. Home and End jump to the start and end of the current week. The grid is CSS grid, with row 1 for day numbers and rows 2+ for event lanes.

Why this is correct

  • Represent calendar days as date-only strings with UTC-only math, so no time zone can shift an event onto the wrong day.
  • Exclusive end dates turn duration, week-crossing, and overlap checks into plain comparisons with no end-of-day special cases.
  • Generate the grid from week boundaries and keep neighboring-month cells as real, navigable dates.
  • Render a multi-day event once per week it crosses, as a clipped column-and-span bar, not once per day.
  • Lane packing places overlapping event bars on separate rows; roving tabindex over the flat day list drives keyboard navigation.

05Testing strategy

Critical behavior

  • Month grid renders correctly across boundary cases (month/year rollover, leap years).
  • Multi-day events render as a single spanning bar, correctly clipped at week/month edges.
  • No timezone-induced off-by-one-day bugs in event placement.
  • Grid supports keyboard navigation with sensible roving focus.

Failure and boundary cases

  • Month/year boundaries (Dec → Jan) and leap years must compute the grid correctly.
  • Time zone handling: an event's displayed day must not shift due to UTC/local conversion bugs, especially near midnight.
  • A multi-day event that starts before the visible month or ends after it should still render its visible portion correctly, clipped at the grid edge.
  • Many overlapping events in one day shouldn't overflow the cell unreadably — cap visible rows with a '+N more' affordance.

Accessibility

  • The grid should be navigable with arrow keys between days, with roving tabindex so Tab doesn't stop on every single day cell.
  • Each day cell needs an accessible name including the full date (e.g. 'Tuesday, March 4, 3 events'), not just the day number.
  • The event creation/edit form must be reachable via keyboard, not only via mouse drag on the grid.

06Performance and production hardening

  • Memoize grid and segments by visible range and event revision.
  • Index events by intersecting date ranges rather than filtering an entire history per cell.
  • Render event bars per week instead of duplicating each day.
  • Use a tested date adapter and explicit locale/time-zone inputs.

QAInterview questions and model answers

Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.

Model answer

A calendar day is not an instant. Date-only representation prevents midnight UTC conversion from shifting an event into an adjacent local day.

Primary references

Ready to build it?

Implement the brief above in your own environment against a timer close to 100 minutes, then self-review against the rubric before moving on.

Back to all briefs →