cf.completefrontend
My progress
SYSTEM DESIGN · #25

Design a calendar week view

A reusable day, 3-day and week time grid that product teams embed, like FullCalendar's time grid or the week view of Google Calendar. Drawing boxes on a grid is easy; the hard parts are an API built on instants with async edit callbacks, mapping time to pixels per day column in a chosen time zone (including 23 and 25 hour days), splitting events at midnight, packing all-day bars, drag to create, move and resize that stays smooth and rolls back on failure, keyboard and screen reader access, localization, and server rendering without knowing the user's time zone.

The question

Our design system needs a calendar week view. Three product teams will embed it: the meetings app, a room booking tool and a shift planner for shops. It shows days side by side with hours running down, and users drag on it to create, move and resize events.

Design the component, not the calendar app. Walk me through its API, how you place events in time, what happens on daylight saving days, how dragging works on mouse, touch and keyboard, and how it stays fast and accessible.

Real-life examples: The week view of Google Calendar, Outlook on the web, day and work week views, FullCalendar's timeGrid views, react-big-calendar's week and day views, Room and shift booking screens in office tools

Requirements: a component, not an app

A week view is a grid. Days run across as columns, time runs down as rows, and each event is a box whose top edge is its start and bottom edge its end. A day view is the same grid with one column; a work week hides the weekend; a 3-day view is what phones show. We call the whole family a time grid.

This question is about the component. The Google Calendar guide designs the whole app: recurring events, sync, sharing, and the overlap layout algorithm with a lab; read it for those, because this guide only links to them. Here the calendar data belongs to whoever embeds us. Our job is the piece every calendar product needs: a grid that turns instants into pixels honestly, handles every gesture, and hands edits back to the host app.

Questions to ask first

Each answer drives a decision later in this guide.
QuestionAnswer we assumeWhat it changes
Who embeds it?Three teams: meetings, room booking, shift planningA small, typed API with render slots. No team can edit our internals, so every need becomes a prop or a callback.
Who owns the data?The host app fetches and saves eventsWe get events as props and report edits through callbacks. We never call a server.
How many events in view?Usually 20 to 60 a week; a room booking screen with 30 rooms overlaid can show 300Layout must be cheap at 300 events, and drag must not redo it 120 times a second.
Which views?Day, 3-day, work week, week; the host controls which oneThe number of columns is a prop. Phones get the 3-day view.
Which time zone?One display zone per grid, chosen by the host, default the user'sAll math runs in that zone, not the device's. Daylight saving days must look right.
Which inputs?Mouse, touch, pen, keyboard, screen readersPointer events with capture, long press on touch, and a full keyboard model for every drag.
Which locales?Global, including right-to-left languagesWeek start, 12 or 24 hour clock, day names and column order all come from the locale.
Server rendering?The meetings app renders on the server with Next.jsThe server doesn't know the user's time zone. We need a plan for the first paint.

Functional requirements

  • Show a day, 3-day, work week or week grid for a range the host controls, with a time gutter on one side and day headers on top.
  • Place timed events by their start and end instants in the grid's time zone, side by side when they overlap.
  • Show all-day and multi-day events as bars in a separate row above the grid.
  • Drag on empty space to create an event, drag an event to move it, including to another day, and drag its top or bottom edge to resize it.
  • Snap every drag to a step, 15 minutes by default, and let the user cancel any drag with Escape.
  • Report every edit to the host through a callback that returns a promise; show the change at once and undo it if the promise rejects.
  • Draw a current-time line and scroll to a sensible hour on first show.
  • Let teams replace how an event and a time slot look, without forking the component.
  • Do all of the above with a keyboard, and announce it to screen readers.

Non-functional requirements

  • Fast first render: placing 300 events in a week should take under 20 ms of script on a mid-range laptop, so switching weeks feels instant.
  • Smooth drag: 60 frames per second on a mid-range Android phone, which leaves about 16 ms per frame for everything, so our pointer handler should stay under 2 ms.
  • Responsive input: every click, key press and drop paints its result within 200 ms, the good threshold for Interaction to Next Paint (INP).
  • Correct time: no event drawn at the wrong hour on any day of the year in any IANA time zone, including the days clocks change.
  • Accessible: WCAG 2.2 AA. Every action that needs a drag also works from the keyboard (success criterion 2.1.1) and with single clicks or taps without dragging (2.5.7, Dragging Movements).
  • Small: about 25 KB gzipped without a date library, because the browser's Intl API already knows every time zone.

Out of scope, and why

  • Recurrence rules, sync, sharing and invitations: app concerns, covered in the Google Calendar guide. The host expands recurring events into instances before passing them in.
  • Month view and resource timelines: different grids. We keep the core reusable so they can be added later, and touch on horizontal virtualization for timelines.
  • Event editing forms: the host opens its own dialog from our callbacks.
Wireframe of the week view, work week of 9 to 13 March 2026. A toolbar with Today, previous and next buttons, the range title and a Week view picker. Day headers Mon 9 to Fri 13. An all-day row with a Conference bar across Monday to Wednesday. A time gutter from 9 AM to 12 PM on the left. Events: Standup on Monday 9 to 10, Plan and 1:1 side by side on Tuesday, Review on Wednesday dimmed while a dashed ghost shows where it will drop at 11, a red now line on Thursday at 10:40, and Lunch on Friday at noon.Work week, 9 to 13 March 2026Today‹ ›Week ▾Mon 9Tue 10Wed 11Thu 12Fri 13Conferenceall day9 AM10 AM11 AM12 PMStandupPlan1:1Reviewghostnow 10:40Lunch
The all-day row sits above the scroller and shares the columns' widths. During a drag the original box dims and a ghost shows the drop target; nothing else moves until the drop.

Check your understanding · Exercise 1

Which answers change the component's design the most (select all that apply)? Select all that apply.

What makes a time grid hard

A first version takes an afternoon: seven div columns, 24 rows of 48 pixels, and each event placed with top = startHour * 48. It looks done. Then real data arrives, and five properties of the problem break it one after another. Every later chapter answers one of them.

Five properties that drive the design

  1. Time is not a straight line on screen. An event is two instants: points on the world's timeline. The grid shows wall-clock hours in one zone. On most days one hour is 48 pixels, but twice a year a day has 23 or 25 hours, and an event can start on one day and end on the next. The mapping from instant to pixel is per column and sometimes bent.
  2. The component doesn't own the truth. The host app owns events and the server decides whether a move is allowed. A room may already be booked. So every edit is a guess that can be refused, and the grid must show the guess at once and undo it cleanly.
  3. Gestures are continuous and fast. A pointer reports its position 60 to 240 times a second. If each report re-runs layout for 300 events and re-renders them, the drag stutters. The hot path during a drag must touch one element, not the whole week.
  4. Space is shared. Overlapping meetings share a column's width, multi-day events share the top row, and on a phone the same finger that drags an event must also scroll the page.
  5. Many people, many settings. A Monday-first week in Berlin, a Sunday-first week in Chicago, a Saturday-first week in Cairo, 12 or 24 hour clocks, right-to-left columns in Hebrew and Arabic, keyboard and screen reader users. None of these can be bolted on later without touching the coordinate math.

Notice what is missing: fetching, caching, sync. Those are the host's job. A component interview rewards depth on the five points above, so we will spend almost all our time on them.

Think first

The naive code computes top = (start.getHours() * 60 + start.getMinutes()) * 0.8. What are two different ways this puts a meeting at the wrong height?

The component API: instants in, promises out

Three teams will build on this API for years, so we design it before any pixels. The lesson on component API design covers the general rules (controlled versus uncontrolled, slots, events). Here we apply them to the three questions only a calendar has: what an event looks like, who controls the visible range, and how edits come back.

What an event is

A timed meeting happens at an instant: the same moment for everyone on Earth, stored as an ISO string with an offset (2026-03-09T15:00:00Z) or epoch milliseconds. An all-day event is different: a holiday on 9 March is 9 March in every zone, so it is a pair of plain dates with no time and no zone. The date picker guide explains this split between instants and dates in depth; we only respect it. Mixing them is the classic bug where a birthday stored as midnight UTC shows on the 8th for users west of Greenwich.

ts
type TimedEvent = {
  id: string;
  title: string;
  start: string;          // instant: "2026-03-09T15:00:00Z"
  end: string;            // exclusive
  allDay?: false;
  timeZone?: string;      // where it was created, shown as a hint only
  editable?: boolean;     // false: no drag handles, no keyboard move
};

type AllDayEvent = {
  id: string;
  title: string;
  allDay: true;
  startDate: string;      // plain date: "2026-03-09"
  endDate: string;        // exclusive: "2026-03-11" means 9th and 10th
};

type CalendarEvent = (TimedEvent | AllDayEvent) & { color?: string; meta?: unknown };
Two kinds of events. End is exclusive in both, so back-to-back meetings never overlap.

The timeZone field is not used for placement. Placement only needs the instant and the grid's zone. The field lets renderEvent show "9:00 AM in Tokyo" next to the local time, which helps travellers.

Who controls what is visible

The host must load events for the visible days, put the range in the URL, and sync the view with its own toolbar. So the range is controlled: the host passes view and date (any plain date inside the range) and we call onRangeChange when the user swipes, presses the next-range key or clicks a day header.

From view, date, the first day of the week and the zone, a pure function derives the list of visible days. We also report the range as instants (rangeStart, rangeEnd) so the host can query GET /events?from=...&to=... without doing zone math itself.

ts
type WeekViewProps = {
  events: readonly CalendarEvent[];
  view: "day" | "3day" | "workweek" | "week";
  date: string;                          // plain date inside the range
  onRangeChange(next: { view: View; date: string;
    rangeStart: string; rangeEnd: string }): void;
  timeZone?: string;                     // IANA name, default: the user's
  locale?: string;                       // BCP 47 tag; pass it so server and client agree
  firstDayOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6;  // default from locale
  minTime?: string; maxTime?: string;    // "07:00", "21:00"
  slotMinutes?: 15 | 30 | 60;            // grid lines, default 30
  snapMinutes?: 5 | 10 | 15 | 30;        // drag step, default 15
  scrollTo?: "now" | string;             // "08:00" for working hours
  renderEvent?(seg: EventSegment, state: DragState): ReactNode;
  renderSlot?(slot: { start: string; end: string }): ReactNode;
  onCreate?(draft: { start: string; end: string; allDay: boolean }): Promise<void>;
  onMove?(change: EventChange): Promise<void>;
  onResize?(change: EventChange): Promise<void>;
  onEventOpen?(id: string): void;        // click or Enter on an event
};
The main props. Render slots and callbacks are optional; defaults cover the simple case.

minTime and maxTime crop the grid for a shop that opens at 7 and closes at 21, which saves 10 hours of scrolling. slotMinutes sets the grid lines and snapMinutes the drag step; keeping them apart lets a room booking tool draw hour lines but snap to 5 minutes. FullCalendar makes the same split with slotDuration (default 30 minutes) and snapDuration (default: the slot size). renderEvent gets a segment, not an event, because one event can be drawn as several pieces (chapter 5).

Decision How should an edit (move, resize, create) get back to the host?

Internal state, then notify

The component copies events into its own state, applies the drop, and calls onChange(nextEvents).

Pros

  • Works with zero host code.
  • Feels instant.

Cons

  • Two sources of truth: when the host refetches, whose version wins?
  • No way to say "the server refused"; the host must push the old list back, and the component can't tell a refusal from a new update.

Fully controlled, callback only

The drop calls onMove(change). Nothing changes on screen until the host passes new events.

Pros

  • One source of truth.
  • Simple to reason about.

Cons

  • If the host waits for the server, the event jumps back to its old place for the length of the request, often a few hundred milliseconds, and then jumps again: a visible flicker.
  • Every host must build its own optimistic layer.

Callbacks return promises, component keeps a pending layer

The drop calls onMove(change), which returns a promise.

Until it settles, the component draws the event at its new place from a small pending overrides map layered over events.

On resolve the override is dropped once the host's events show the new times; on reject it is dropped at once and the event animates back.

Pros

  • Instant feedback in every host with no extra code.
  • The host stays the source of truth.
  • Failure has one clear path: reject the promise.

Cons

  • The contract must be clear: resolve only after the host's data holds the new value.
  • Two quick moves of one event need ordering (we keep only the newest override).

What we pick: Promise-returning callbacks with a pending layer. It is how FullCalendar's eventDrop with revert() works in spirit, but a promise is easier to compose with fetch and with data libraries such as TanStack Query. The optimistic updates lesson covers the general pattern; chapter 8 shows our version.

Check your understanding · Exercise 2

A shift planner team asks for these. Which belong in our API (select all that apply)? Select all that apply.

Time to pixels: one map per day column

Everything on the grid, events, the now line, the drop target under the pointer, comes from one pair of functions: yOf(instant) and instantAt(y). Get them right once and every feature is right. Get them wrong and bugs appear on two days a year, in some zones, in bug reports nobody can reproduce.

We use 48 pixels per hour, so 0.8 pixels per minute and 12 pixels per 15 minute row. A full day is 1,152 pixels tall. Each day column covers the instants from the start of its date in the grid's zone to the start of the next date. We say "start of the date", not midnight, because a few zones move their clocks at midnight (Cuba still does; Brazil did until 2019), so on those days 00:00 never happens and the day begins at 01:00.

Reading the wall clock in any zone

We never call getHours(), which answers in the device's zone. We ask Intl.DateTimeFormat with the grid's timeZone for the wall-clock hour and minute of an instant, using hourCycle: "h23" so midnight reads as 0, and formatToParts so we get numbers, not a string to parse. Creating a formatter is slow (it loads locale and zone data), so we create one per zone and reuse it. Temporal's ZonedDateTime does the same job with a nicer API; the date picker guide covers when to use it or its polyfill.

The two days a year that break the simple formula

In New York on Sunday 8 March 2026 clocks jump from 02:00 to 03:00: the day has 23 hours and 02:30 never happens. On Sunday 1 November 2026 they fall back from 02:00 to 01:00: the day has 25 hours and 01:30 happens twice, first in daylight time (EDT, UTC minus 4) and then in standard time (EST, UTC minus 5). Berlin changes on 29 March 2026 (02:00 jumps to 03:00) and 25 October 2026 (03:00 falls back to 02:00). Lord Howe Island in Australia shifts by only 30 minutes.

The Google Calendar guide draws these days with plain wall-clock rows and patches two edge cases, which suits an app whose nights are mostly empty. A component that a shift planner embeds can't assume that: night shifts cross the change every year. The task says to draw these days honestly, so here are three ways to try.

Decision How do we map an instant to a y position in a day column?

Share of the elapsed day

y = (t - dayStart) / (dayEnd - dayStart) * 1152. A 23 hour day is squeezed into the same height.

Pros

  • Every instant has exactly one place.
  • One line of code.

Cons

  • On 8 March each hour is about 50 pixels, so 09:00 sits at 401 px while the gutter's 9 AM line is at 432 px: events on that day miss the labels by up to 44 px.
  • A meeting at 9 is drawn at the gutter's 8:21 mark.

Pure wall clock

y = wallMinutes(t) * 0.8. Position follows the clock face.

Pros

  • Always lines up with the gutter.
  • Simple and fast.

Cons

  • Spring: the 2 AM band is just empty, and dropping there creates a 02:30 that does not exist.
  • Fall: 01:30 EDT and 01:30 EST land on the same pixel. Two different meetings stack on top of each other, and a meeting from 01:30 EDT to 01:30 EST, a real hour, gets zero height.

Per-column step map, aligned to the gutter

Walk the column's real instants in 15 minute steps and give each its wall-clock row.

Rows no instant reaches are a gap: drawn hatched, labelled, and refused as a drop target.

Rows reached twice are a repeated block: the first pass takes its top half, the second pass the bottom half, with a dashed line and both offsets labelled.

Pros

  • Lines up with the gutter on every other row.
  • Every instant has one place and every place one instant, so drop targets are never ambiguous.
  • The odd hour is visible, not hidden.

Cons

  • A repeated hour is drawn at half height, so a 30 minute meeting in it looks like 15.
  • More code, and it must be tested in many zones.

What we pick: The step map. It keeps the shared gutter honest for 23 of 24 rows and makes the odd row visibly odd. A fourth option, adding a full extra 1 AM row to the whole week on fall back day, keeps heights true but changes the gutter for one week a year and leaves six columns with a dead row; we mention it and move on.

Three day columns in New York from midnight to 5 AM, all aligned to one gutter with rows 12 AM to 4 AM. Saturday 7 March is a normal day: 24 hours in 24 rows, with an event from 1:30 to 2:30. Sunday 8 March has 23 hours: the 2 AM row is a hatched gap marked no 2 AM, with no instants in it, because clocks jump from 2:00 to 3:00. Sunday 1 November has 25 hours: the 1 AM row is split in two halves, the top half for the first 1 AM in daylight time, EDT, and the bottom half for the second 1 AM in standard time, EST.12 AM1 AM2 AM3 AM4 AMSat 7 Mar24 hoursSun 8 Mar23 hours, 1 gapSun 1 Nov25 hours, 1 doubled1:30 to 2:30no 2 AM1st, EDT2nd, ESTone gutter, three different maps
Every row except the odd one lines up with the shared gutter. The gap refuses drops; the doubled row gives each pass of the repeated hour its own half, so no two instants share a pixel.
ts
const STEP = 15 * 60_000;
type Step = { t: number; y: number; h: number }; // instant, top, height

function buildColumn(dayStart: number, dayEnd: number, px: number): Step[] {
  const steps: Step[] = [];
  for (let t = dayStart; t < dayEnd; t += STEP) {
    steps.push({ t, y: rowOf(t) * px, h: px }); // rowOf: wall minutes / 15
  }
  for (let i = 1; i < steps.length; i++) {
    // Compare wall rows, not y: earlier passes of this loop rewrite y.
    const n = rowOf(steps[i - 1].t) + 1 - rowOf(steps[i].t); // rows repeated
    if (n <= 0) continue;            // 0: normal step; < 0: a gap, left empty
    const top = steps[i].y;          // first pass in top halves, second below
    for (let k = 0; k < n; k++) {
      steps[i - n + k] = { ...steps[i - n + k], y: top + (k * px) / 2, h: px / 2 };
      steps[i + k] = { ...steps[i + k], y: top + ((n + k) * px) / 2, h: px / 2 };
    }
    i += n - 1;                      // skip the second pass we just placed
  }
  return steps;
}

function yOf(steps: Step[], t: number): number {
  const s = steps[lastAtOrBefore(steps, t)];        // binary search on t
  return s.y + ((t - s.t) / STEP) * s.h;
}
Building one column's map. px is 12 (pixels per 15 minute row). Every UTC offset in use today is a multiple of 15 minutes. We ran it for New York, London, Lord Howe and Havana transition days: y always increases and 9:00 lands at 432 px.

instantAt(y) is the reverse: find the step whose band contains y and interpolate. If y falls in a gap, it returns the first instant after the gap (03:00), so a drop there lands on the next real time. minTime and maxTime just subtract the y of the first visible row and clip.

Cost: a column has 92 to 100 steps, each one formatToParts call. Most days need none of that. If the UTC offset at the day's start equals the offset at its end and the day is exactly 24 hours long, the map is a straight line, y = (t - dayStart) / 60000 * 0.8, and we skip the walk. Only transition days build steps, and we cache maps by zone and date, so switching weeks back and forth costs nothing.

Zone
Week
Mapping
New York, Sun 8 Mar 2026, 23 hours long, drawn with the step mapping. A night shift from Saturday 23:00 to Sunday 03:30 is split into a Saturday segment from 23:00 to the end of the day and a Sunday segment from the start of the day to 03:30. On Sunday it lasts 2.5 real hours and is drawn as 3.5 hours. A 9:00 meeting is drawn at 09:00 on the gutter. Gap row hatched and refused; every other row matches the gutter.SaturdaySunday, 23 h00:0003:0006:0009:0012:0015:0018:0021:0024:00shift9:00 meetinghatched: gaptwo tones: repeatdashed: 9:00 lineNew York, Sun 8 Mar 2026: 23 hoursshift on Sunday: 2.5 h real, 3.5 h drawn, 1 h of it the gap9:00 meeting drawn at 09:00
Gap row hatched and refused; every other row matches the gutter.

Check your understanding · Exercise 3

New York, Sunday 1 November 2026. Which statements about our step map are true (select all that apply)? Select all that apply.

Cutting events into segments, and packing the all-day row

Before anything is drawn, a pure function turns events into segments: the pieces that actually appear in one column or one row. Segments are what renderEvent receives, what the overlap layout arranges, and what the keyboard walks through. Keeping this step pure (same input, same output, no DOM) lets us memoize it and unit test it with fixed zones and dates.

Events that cross midnight

A night shift runs from 22:00 Monday to 06:00 Tuesday. Clip the event's [start, end) to each column's [dayStart, dayEnd) and you get two segments: Monday 22:00 to the end of Monday, and Tuesday from its start to 06:00. Each segment remembers startsBefore and endsAfter, so renderEvent can square off the cut edge and only the first piece shows the title in full. Both pieces share the event id: hover one and both highlight, drag one and the whole event moves.

A night shift from Monday 22:00 to Tuesday 06:00 is one event with one start and one end. It is clipped to each day column: Monday's column, shown from 18:00 to 24:00, gets a segment from 22:00 to the end of the day, marked endsAfter. Tuesday's column, shown from 00:00 to 06:00, gets a segment from the start of the day to 06:00, marked startsBefore. Both segments share the event id.Night shift, Mon 22:00 to Tue 06:00Mon 9Tue 1018:0024:0000:0006:0022:00 to endstart to 06:00endsAfterstartsBefore
Clip [start, end) to each column's [dayStart, dayEnd). The flags tell renderEvent which edge is a cut, so it can square it off and show the title once.

Where does a three-day conference with set times go? Cutting it into three full-height columns hides every other meeting. Many calendars move timed events of 24 hours or more into the all-day row, and we do the same by default: under 24 hours become segments, 24 hours or more become bars. A prop can change the threshold for a shift planner whose 26 hour shifts must stay in the grid.

Overlapping events, briefly

Within a column, segments that overlap in time sit side by side. The Google Calendar guide derives that layout step by step, with a lab: clusters of overlapping events, a greedy sub-column for each, and a stretch into empty columns to the right. We reuse it unchanged on segments. It costs O(n log n), so 300 events take well under a millisecond.

One detail matters for a component: overlap must use the drawn height, not the real one. A 5 minute check-in is 4 pixels tall, too short to click, so we draw at least 20 pixels (25 minutes at our scale). If two 5 minute events start 10 minutes apart, their real times don't overlap but their boxes do. Running the layout on drawn extents puts them side by side instead of on top of each other.

Packing the all-day row into lanes

All-day and long events are horizontal bars above the grid. Each bar covers a range of visible day indices, clipped to the week. We place bars in lanes (rows of bars) with a greedy rule: sort by first day, longer bars first, then give each bar the lowest lane that is free on every day it covers. Longer first keeps the long bars on top and steady from week to week, which users notice.

Five bars fit in three lanes. Offsite reuses lane 1 because Conference ended on Wednesday.
Bar (sorted)DaysLanes busy on those daysLane given
On callMon to Sunnone0
ConferenceMon to Wed01
LeaveTue0, 12
Sprint reviewWed to Fri0, 1 on Wed2
OffsiteThu to Fri0, 21
The all-day row for a week from Monday to Sunday with three lanes. Lane 0: On call from Monday to Sunday. Lane 1: Conference from Monday to Wednesday, then Offsite on Thursday and Friday. Lane 2: Leave on Tuesday, then Sprint review from Wednesday to Friday. Each bar took the lowest lane free on all of its days, longest first.MonTueWedThuFriSatSunlane 0lane 1lane 2On callConferenceLeaveSprint reviewOffsitethe hours scroll below this line; the row stays put
Greedy packing, longer bars first: five bars fit in three lanes. Offsite reuses lane 1 because Conference ended on Wednesday. A fourth lane would collapse into a +1 more button per day.

The row must not eat the grid. We show at most 3 lanes (24 pixels each) and replace the rest with a "+2 more" button per day that opens a popover listing them. A toggle can expand the row. The all-day row sits outside the vertical scroller, so it stays visible while the hours scroll under it, and it shares the day columns' widths so bars line up with the grid.

Check your understanding · Exercise 4

Which statements about segments are true (select all that apply)? Select all that apply.

Rendering: boxes, the now line, and a fast drag path

We know where every segment goes. Now we choose how the browser draws it and how much we draw. Then come two small features that are easy to get subtly wrong, the current-time line and the hour the grid opens at, and finally how we keep a drag at 60 frames per second when the page holds 300 events.

Decision How do we put event boxes on the grid?

CSS Grid rows

Each column is a CSS grid with one row per 5 minutes; an event gets grid-row: 109 / span 18.

Pros

  • No pixel math in script.
  • Rows resize with the container.

Cons

  • Positions snap to the row size, so a 09:07 start can't be drawn.
  • Side by side overlaps need sub-columns per cluster, which grid can't express without nesting.
  • A DST gap or half-height repeated hour is awkward to express in grid lines.

Absolute boxes inside each day column

Each column is position: relative. Each segment is position: absolute with top and height in pixels from the step map, and inset-inline-start and width in percent from the overlap layout.

Pros

  • Any minute, any DST shape, any overlap layout.
  • Each box is a real element: focusable, readable by screen readers, styleable by renderEvent.
  • A column is a natural memo boundary.

Cons

  • We own the math (already done in chapter 4).
  • Text in narrow boxes needs care: clip and show the full title on hover and focus.

Canvas

Draw the grid and events on a canvas, hit-test pointer positions in script.

Pros

  • Thousands of events at no DOM cost.

Cons

  • No elements for screen readers, focus or text selection: we would rebuild all of it.
  • renderEvent can't return React content.
  • 300 events don't need it.

What we pick: Absolute boxes inside day columns. Canvas only pays off at thousands of boxes, which a week never has; a resource timeline with 200 rooms might, and that is a different component. Using logical properties (inset-inline-start) means right-to-left layouts flip with no extra code.

How much to render

A day is 1,152 pixels tall, about two screens. Virtualizing hours (rendering only the visible rows) saves almost nothing: the grid lines are a background, slot cells are 336 empty elements for a week at 30 minutes, and events are at most a few hundred. The virtualized list guide's techniques are not needed vertically.

Horizontally is different. On a phone the user swipes between 3-day pages and expects the next page to be there as the finger moves, not after it lifts. So we render the current range plus one range on each side in a horizontal scroller with scroll-snap-type: x mandatory, and when a swipe settles we shift the window and call onRangeChange.

Neighbors get content-visibility: auto, so the browser skips their layout and paint until they scroll into view, plus contain-intrinsic-size so a skipped page keeps its width. A scrolling timeline that shows months of days side by side is where horizontal virtualization pays off: render only the day columns in view plus a buffer, exactly as a virtualized list does for rows.

Two render windows. Top: on a phone, three pages sit side by side in a scroll-snap strip: previous week, this week on screen, and next week. The neighbors exist but are not painted until they scroll into view. Bottom: a timeline of 14 day columns where only the 6 visible columns and a buffer of 1 on each side are rendered; the rest do not exist in the DOM.phone: scroll-snap between three pagesprevious weekthis weeknext weeknot painted yeton screennot painted yettimeline: 14 days, only the visible ones and a buffer existfaded: not rendered, hatched: buffer, outlined: visible
A week view renders three pages so a swipe never shows blank space, and content-visibility: auto keeps the neighbors cheap. A long timeline virtualizes columns the way a list virtualizes rows.

A line that moves once a minute

At 0.8 pixels per minute the line moves less than one pixel per minute. Animating it at 60 frames per second would wake the main thread 3,600 times to move it once. setInterval(tick, 60000) is better but out of phase: started at 10:14:58, it updates at 10:15:58, so the line lags the device clock by up to a minute.

We align the timer to the minute: wait 60000 - (Date.now() % 60000) ms, update, then schedule the next one the same way. Each update recomputes the position with yOf(now) from the column's step map, so the line is right on DST days too, and moves the line with a transform.

ts
function useMinuteClock(): number | null {
  const [now, setNow] = useState<number | null>(null); // no time during SSR
  useEffect(() => {
    let timer = 0;
    const schedule = () => {
      setNow(Date.now());
      timer = window.setTimeout(schedule, 60_000 - (Date.now() % 60_000) + 50);
    };
    const onVisibility = () => {
      clearTimeout(timer);
      if (document.visibilityState === "visible") schedule();
    };
    schedule();
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      clearTimeout(timer);
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);
  return now;
}
A minute-aligned clock that sleeps while the tab is hidden. It reads the time only in an effect, never during render.

The clock starts as null, so the server and the hydrating client render the same HTML with no line, and the first effect fills it in. The extra 50 ms makes sure we wake just after the minute ticks over, not just before. When the tab is hidden we stop the timer; browsers throttle timers in background tabs anyway, so a hidden timer would fire late and pointlessly.

When the tab comes back we update at once, so a laptop opened after lunch shows the right time immediately. The same clock moves the "today" highlight at midnight, and a week view whose range no longer contains today hides the line. One clock serves the whole component, and only the line and today's header subscribe to it; if the whole grid read now, every minute would re-render 300 events.

Updates of the now line over four minutes, from 10:14 to 10:17. Every frame: a solid band of updates, 60 per second. setInterval started at 10:14:58: updates at 58 seconds past each minute, so the line lags the clock for most of each minute. Aligned timeout: updates exactly at 10:15:00, then the tab is hidden from 10:15:30 to about 10:16:25 with no updates, an immediate update when it becomes visible, and the next one at 10:17:00.every framesetIntervalalignedtab hidden10:1410:1510:1610:17
The aligned timer does one update per minute, right on the minute, and none while hidden. On visibilitychange it updates at once, then lines up with the minute again.

Opening at the right hour

A grid that opens at midnight shows 8 hours of empty night. The scrollTo prop decides: "now" puts the current time about a third of the way down the viewport when today is visible, and otherwise scrolls to a time like "08:00". On the client we set scrollTop in a layout effect, after the DOM exists but before the browser paints, so the user never sees the jump from midnight.

  • When the user moves to the next week, keep the current scroll position. Jumping back to 8:00 on every swipe is disorienting. FullCalendar's scrollTime (default 06:00) does jump back on each range change unless scrollTimeReset is turned off, so this is a real choice.
  • When the view changes from week to day, keep the same time at the top of the viewport.
  • On a server render the browser paints the HTML before React hydrates, so a layout effect can't hide the jump. For a fixed hour like "08:00", a tiny inline script right after the scroller sets scrollTop as soon as the element is parsed, usually before the first paint. "now" needs the zone, so it waits for hydration unless the host passes a known zone.

Check your understanding · Exercise 5

Which statements about the now line are true (select all that apply)? Select all that apply.

The drag hot path

The naive drag puts the pointer position in React state on every pointermove. Each move re-renders the column, re-runs the overlap layout, and re-renders every event. On a busy week that can be 5 to 15 ms of work, repeated 60 to 120 times a second on a phone that has 16 ms per frame for everything, so the drag stutters and INP suffers. The runtime performance lesson explains why work per frame matters; here is our split.

  1. pointermove only stores the latest coordinates and asks for one requestAnimationFrame if none is pending. Many moves in one frame cost one update.
  2. Inside the frame we call instantAt(y), snap to snapMinutes, and compare with the last snapped value. If nothing changed, we stop: a drag inside one 15 minute step costs nothing more.
  3. When the snapped time changes we move one ghost element with transform: translate(...) and set its height. Transforms skip layout, so only that layer is repainted.
  4. The original box dims in place. Other events do not make room while the pointer moves; that would re-run layout 60 times a second and make boxes jump under the user's finger.
  5. On drop we write the change into the pending layer. The two affected columns (old day and new day) re-run segmenting and overlap layout once. Columns are memoized by their segment list, so the other five don't re-render.

Check your understanding · Exercise 6

A team reports that dragging stutters on a week with 250 events. Which changes help (select all that apply)? Select all that apply.

Drag to create, move and resize

Three gestures share one controller: press on empty space and drag to create, press on an event's body to move, and press on its top or bottom edge to resize. The mechanics match the kanban board guide (pointer events, capture, a movement threshold, long press on touch, a ghost moved with transforms), so we focus on what a time grid adds: snapping, two axes, edges that scroll, and gaps in time.

One set of listeners for mouse, pen and touch

We listen for pointerdown on the grid, not on each event, and use the target to decide which gesture it is. Then setPointerCapture(pointerId) sends every later move and the final pointerup to the grid, even when the pointer leaves the column or the window. A drag starts only after 5 pixels of movement, so a plain click still opens the event. Column rectangles are measured once at the start of the drag; vertical position is converted with the scroller's current scrollTop at each frame, so auto-scroll can't desync it.

ts
function onPointerDown(e: PointerEvent) {
  if (e.button !== 0) return;
  const hit = hitTest(e.target);   // create | move | resize-start | resize-end
  if (!hit || !canEdit(hit)) return;
  grid.setPointerCapture(e.pointerId);
  drag = { hit, phase: "pressed", origin: [e.clientX, e.clientY],
           cols: measureColumns(), grabOffsetMin: offsetInEvent(hit, e) };
  if (e.pointerType === "touch") {
    drag.timer = setTimeout(lift, hit.kind === "create" ? 500 : 250);
  }
}

function onPointerMove(e: PointerEvent) {
  if (!drag) return;
  drag.last = [e.clientX, e.clientY];
  if (drag.phase === "pressed") {
    if (distance(drag.origin, drag.last) < 5) return;
    if (e.pointerType === "touch") return reset(); // moved first: a scroll
    lift();                                        // mouse and pen lift now
  }
  drag.frame ??= requestAnimationFrame(update);
}
The start of the controller. update() runs once per frame: snap, compare, move the ghost.

Touch: let the page scroll until a long press

On a phone the whole grid is a scroll area, so a finger on it usually means "scroll". We set touch-action: manipulation, which keeps panning and pinch zoom and only turns off double-tap zoom, as dnd-kit recommends for touch dragging. A drag starts only after the finger rests: 250 ms with less than 5 pixels of movement on an event (the kanban guide's numbers), and 500 ms on empty space, where people most often put a finger to scroll. For comparison, react-big-calendar's longPressThreshold defaults to 250 ms and FullCalendar's longPressDelay to a full second.

When the timer fires we lift, call navigator.vibrate(10) where supported, and from then on call preventDefault() in a non-passive touchmove listener, which is what actually stops the page from scrolling. Changing touch-action after the touch began has no effect. We also cancel the contextmenu event during a lift so the long press menu does not pop up.

Snapping and what each gesture keeps

  • Snap in wall-clock time. Convert y to an instant, read its wall-clock minutes, round to snapMinutes, convert back. If the rounded time falls in a DST gap, push it to the first real time after the gap.
  • Move keeps the grab point and the real duration. If you grab a meeting 20 minutes below its top, the top stays 20 minutes above the pointer. The end is newStart + duration, so a one hour meeting dragged across the spring forward night is still one real hour.
  • Move across days reads the column under the pointer from the measured rectangles; in right-to-left layouts the first column is on the right, which the rectangles already reflect.
  • Resize changes only one edge, never past the other one: the minimum length is one snap step. A bottom edge stops at the end of the visible day; longer events are edited in the host's form.
  • Create spans from the pressed slot to the current slot, in either direction. A click without a drag creates a default 30 minute event, and a click on an event opens the host's form (onEventOpen), where its times can be typed: these are the single-pointer paths that WCAG 2.5.7 asks for.

Auto-scroll, crossing weeks, and Escape

When the pointer is within 40 pixels of the scroller's top or bottom edge, a frame loop scrolls by up to 15 pixels per frame, faster the closer it gets, and re-runs update() because the content moved under a still pointer. Holding near the left or right edge for 600 ms moves the range by one page and calls onRangeChange, so an event can be dragged into next week. Escape, pointercancel and lostpointercapture all cancel: the ghost disappears, the original box returns, no callback fires.

State machine of the drag controller. From idle, pointerdown goes to pressed. From pressed, a click without movement goes back to idle and opens the event, and so does a touch that moves before the long press, which is a scroll; 5 pixels of movement with a mouse or a long press on touch goes to dragging. From dragging, Escape or pointercancel goes back to idle, and a drop goes to saving. From saving, a resolved promise goes to idle, and a rejected one goes to rollback, which slides the event back, announces it, and returns to idle. A note says Space on a focused event goes straight from idle to dragging.idlepresseddraggingsavingrollbackpointerdownclick or scroll5 px or long pressEscape, pointercanceldropresolverejectslide backSpace: idle to draggingkeys move the ghost
Every path ends in idle, and only one path, the drop, reaches the host. Escape and pointercancel leave no trace, and a rejected save always passes through rollback.
Sequence diagram of drag to create, between the User, the Controller, the Pending layer and the Host. The user presses at 9:00 and drags to 10:30. The controller shows a ghost that snaps to 15 minutes. The user releases. The controller adds draft-7 to the pending layer, marked saving, and calls onCreate with the draft on the host. The host resolves after its events include the real event evt_81. The pending layer removes the draft and the grid renders the real event.UserControllerPendingHostdrag to 10:30ghost, 15 min snapreleaseadd draft-7, savingonCreate(draft)evt_81 in eventsdraft gone, evt_81
The draft appears at once, before any network call. The host may open its own title dialog before resolving; the draft simply waits. On reject the draft just disappears.
Keyboard target

Drag on empty space to create, drag a box to move it, drag its top or bottom edge to resize. Escape cancels.

A three-day grid from 9:00 to 13:00. Events: Standup, Mon 9:00 to 9:30; Review, Tue 10:00 to 11:00.MonTueWed9:0010:0011:0012:0013:00StandupReviewpointer moves: 0, ghost updates: 0, saving: 0
Drag on empty space to create, drag a box to move it, drag its top or bottom edge to resize. Escape cancels.

Check your understanding · Exercise 7

On a phone, a user puts a finger on an event and immediately swipes up. What should happen (select all that apply)? Select all that apply.

Saving edits: show it now, undo it on failure

A drop is a request, not a fact. The room may be taken, the user may have lost edit rights a minute ago, or the network may be down. Chapter 3 chose the contract: onMove, onResize and onCreate return promises, and the component keeps a pending layer of overrides on top of the host's events. Here is how that layer behaves.

ts
type Pending = { start: string; end: string; v: number; status: "saving" | "saved" };
let version = 0;

async function commit(id: string, next: Times, save: () => Promise<void>) {
  const v = ++version;
  setPending((m) => new Map(m).set(id, { ...next, v, status: "saving" }));
  try {
    await save();                    // contract: the host's data holds "next"
    setPending((m) => (m.get(id)?.v === v ? new Map(m).set(id, { ...next, v, status: "saved" }) : m));
  } catch (err) {
    setPending((m) => (m.get(id)?.v === v ? without(m, id) : m));
    if (latest(id) === v) announce(failMessage(id, err));
  }
}

// Saved overrides go once the host's next events arrive.
useEffect(() => setPending((m) => withoutSaved(m)), [events]);

// What we draw: the host's events with pending overrides on top.
const shown = useMemo(
  () => events.map((e) => {
    const p = pending.get(e.id);
    return p ? { ...e, start: p.start, end: p.end, saving: p.status === "saving" } : e;
  }),
  [events, pending],
);
The pending layer. Only the newest change per event may clear or roll back.

On success the override is marked saved and stays until the host's next events prop arrives; then it goes and nothing moves. The contract asks the host to update its data before resolving: with TanStack Query, an onMutate or onSuccess cache write, both of which run before mutateAsync resolves. But the re-render can come a tick later, because TanStack Query batches its notifications with setTimeout(0) by default, so dropping the override at once would flash the old time for a frame.

On failure the override is removed at once and the event slides back to the time the host still has. The slide is a 150 ms transform transition, skipped when prefers-reduced-motion: reduce is set.

Two quick moves of one event

A user drops a meeting at 10:00 (version 1), changes their mind and drops it at 11:00 (version 2) before the first save returns. If version 1 then fails, rolling back would throw the meeting to its original 9:00 while the 11:00 save is still in flight. The version check prevents it: a settling promise only touches the override if it is still the newest.

Ordering on the server (the 10:00 request arriving after the 11:00 one) is the host's problem. The usual fix is to send an If-Match header with the event's version, so the server refuses a stale write with 412. The optimistic updates lesson covers both sides.

Creating has no id yet

A new event has no server id. On drop we draw a draft with a local id like draft-7, marked as saving, and call onCreate(draft). Many hosts open a title dialog at this point; the promise simply stays pending until the user saves or cancels. When it resolves, the host's events gain the real event with its server id, and the draft goes as they arrive; when it rejects (including a cancelled dialog), the draft disappears.

Sequence diagram of a move that fails, between User, Grid, Pending, Host and Server. The user drops the Review event at Wednesday 11:00. The grid writes override version 2 at 11:00 into the pending layer and re-renders, so the user sees it at 11:00. The grid calls onMove on the host, which sends a PATCH for event 42 to the server. The server answers 409 because the room is booked. The host rejects the promise. The grid drops the override, the event slides back, and the failure is announced in the live region.UserGridPendingHostServerdrop, 11:00v2 at 11:00shown at 11:00onMovePATCH409, bookedrejectsslide back
The user sees the new time at once and the old time again only if the server refuses; the slide back is also announced. The grid never remembers the old time itself: dropping the override reveals whatever the host still has.

Telling the user what went wrong

  • The host rejects with an error that may carry a reason, such as "Room 4 is booked from 10:00 to 11:00". We pass it to an onError callback so the host can show its own toast.
  • We also write one sentence to a polite live region: "Couldn't move Design review. It is back on Tuesday at 9:00." Screen reader users would otherwise miss the slide back.
  • While saving, renderEvent receives saving: true, so teams can show a subtle stripe. We don't block further drags on that event; the version rule handles them.

Check your understanding · Exercise 8

Version 1 moves an event to 10:00; version 2 moves it to 11:00. Version 1's promise rejects while version 2 is still saving. What should the grid do (select all that apply)? Select all that apply.

Keyboard and screen readers

A time grid is one of the hardest widgets to make accessible, because its meaning is spatial: "this box is next to that box at 10 o'clock on Tuesday". A screen reader user can't see any of that, and a keyboard user can't drag. Both must still be able to read the week, create, move and resize. The keyboard navigation and ARIA patterns lessons cover roving tabindex and the APG grid pattern; here we decide how a calendar uses them.

Decision How does a keyboard user move around the grid?

Everything is a tab stop

Every slot cell and every event gets tabindex="0".

Pros

  • No custom key handling.

Cons

  • 336 slots plus every event: reaching Friday afternoon takes 300 Tab presses.
  • Tab order follows the DOM, which is day by day, not what the user wants.

One grid of slots that also holds events

An APG grid where rows are time slots and columns are days; events are inside cells.

Pros

  • One tab stop and a well-known pattern.

Cons

  • An event that spans six slots can't live in six cells.
  • Overlapping events in one cell need a second level of navigation that no screen reader user will guess.

Two composite widgets plus an agenda

An event layer: one tab stop, arrow keys move between events (Up and Down by time within a day, Left and Right to the nearest event on the next day).

A slot grid: one tab stop, an APG grid of empty time slots for creating.

A list view of the same events grouped by day, one toggle away.

Pros

  • Reading the week is fast: arrow through events in time order.
  • Each widget has one clear job.
  • The list view gives screen reader users a plain, familiar structure.

Cons

  • Two widgets to explain in the help text.
  • More code and more tests.

What we pick: Two composite widgets plus the list view. Reading events is the common task, so it gets the simple model. Creating at an exact slot is rarer and gets the grid. The list view is not a fallback for us to forget; it renders from the same segments and is often what screen reader users prefer.

The keys

Keys only act while focus is inside the component, so they don't clash with the page's own shortcuts.
WhereKeyDoes
Slot gridArrowsUp and Down one slot, Left and Right one day (flipped in right-to-left layouts)
Slot gridPage Up, Page DownOne hour up or down
Slot gridHome, EndFirst and last day of the range
Slot gridEnterCreate a default event at the slot (calls onCreate)
Event layerArrowsPrevious or next event in time; nearest event on the next or previous day
Event layerEnterOpen the event (onEventOpen)
Event layerSpacePick up the event to move it, as on the kanban board
MovingUp, DownMove by one snap step (15 minutes)
MovingLeft, RightMove to the previous or next day at the same time
MovingShift + Up, Shift + DownResize: move the end by one snap step
MovingSpace or EnterDrop and save (calls onMove or onResize)
MovingEscapeCancel and put it back
AnywhereAlt + Page Down, Alt + Page UpNext and previous range (calls onRangeChange)
The keyboard model as two regions. Left, the event layer, tab stop 1: Standup, then Review, which has focus, and 1:1 on the next day. Up and Down go to the previous or next event, Left and Right to the other day, Space picks up the event and Enter opens it. Right, the slot grid, tab stop 2: three days by four slots with one slot focused. Arrows move by slot or day, Page Up and Page Down by an hour, and Enter creates an event there.events: tab stop 1slots: tab stop 2StandupReview1:1Up, Down: previous, nextLeft, Right: other daySpace: pick up to moveEnter: openArrows: slot or dayPage Up, Down: an hourEnter: create here
Two tab stops instead of hundreds. Reading the week means arrowing through events in time order; creating at an exact time uses the slot grid.

A screen reader passes arrow keys to the page only in focus mode, and most switch to that mode when focus enters a widget with a composite role such as grid; on a plain div of buttons they keep the arrows for reading. The ARIA patterns lesson explains both modes; for us it means both layers need a role. The slot grid is an APG grid. The event layer can be a layout grid too: one column per day, that day's events down it in time order, so moving sideways announces the new day.

What a screen reader hears

Each event is a button whose accessible name says what sighted users see at a glance: "Design review, Tuesday 10 March, 9:00 to 10:00 AM, Room 4". We build it with Intl.DateTimeFormat in the grid's zone and locale; formatRange produces "9:00 to 10:00 AM" style ranges in each language's own pattern. A segment adds "continues from Monday" or "continues to Wednesday", and overlaps add "1 of 3 at this time", so the user knows to arrow further. The slot grid's cells are named "Tuesday 10 March, 9:30 AM".

Moves happen without focus changing to anything new, so we narrate them in one polite live region (aria-live="polite") shared by the component. Each message is short, because a fast run of arrow presses would otherwise queue long sentences.

Picking up: "Picked up Design review. Arrows to move, Shift and arrows to change the end, Space to drop, Escape to cancel." Each step: "Tuesday 9:15 to 10:15 AM." Dropping: "Moved to Tuesday 9:15. Saving." Then either nothing more, or "Couldn't move Design review. It is back on Tuesday at 9:00."

Keeping focus through moves

A moved event may re-render in another column, which means a new DOM element and lost focus. We remember the focused event id and, after each commit or rollback, focus the element with that id again. After onCreate, the host usually opens a dialog; when it closes we focus the new event, or the slot that started it if creation was cancelled.

Check your understanding · Exercise 9

Which choices help screen reader users the most (select all that apply)? Select all that apply.

Locales, right-to-left, and the server that doesn't know your time zone

Everything a locale changes

The locale formatting lesson covers Intl in general. A calendar uses five parts of it, and each one shows up in the layout, not just in text.

Five locale facts that change layout. getWeekInfo() became Baseline in 2026, when Firefox 153 shipped it; Chrome and Edge 99 to 129 exposed it as a weekInfo property, so we feature-detect both and keep a small fallback table.
WhatExampleWhere it comes from
First day of the weekMonday in Germany, Sunday in the US and Israel, Saturday in Egyptnew Intl.Locale(locale).getWeekInfo().firstDay (1 is Monday, 7 is Sunday); a firstDayOfWeek prop overrides it
Weekend, for the work week viewSaturday and Sunday in most places, Friday and Saturday in IsraelgetWeekInfo().weekend; the work week shows the other days
12 or 24 hour clock"9 AM" in the US, "09:00" in FranceresolvedOptions().hourCycle of a formatter with hour: "numeric"; a prop can force either
Day header formatWeekday and day number, in the order and punctuation each language usesIntl.DateTimeFormat with weekday: "short", day: "numeric"; never glue parts together ourselves
Digits and text directionArabic-Indic digits, right-to-left columnsIntl formats digits; direction comes from the host's dir

Our own strings ("All day", "+2 more", the announcements) come from a messages prop with plural rules, with English defaults. The week start feeds the range function from chapter 3, so it must be settled before we compute which days are visible: changing it later would shift the whole grid.

Right-to-left

In Hebrew and Arabic the first day of the week is the rightmost column and the time gutter sits on the right. Because boxes are placed with inset-inline-start and the columns are a flex row, setting dir="rtl" flips all of it. What does not flip by itself is script: hit testing reads measured column rectangles, so it is already correct; the keyboard must swap Left and Right; and a bar that continues into next week shows its cut edge on the left. Time still runs top to bottom in every language.

Server rendering without a time zone

The meetings app renders on the server for a fast first paint. The server knows the locale from Accept-Language, but a plain request carries no time zone. If the server places events using its own zone (often UTC), a user in New York sees every meeting four or five hours off until JavaScript runs and moves them.

Worse, React's hydration expects the first client render to match the server HTML exactly. A different time label is a hydration mismatch, which React reports as an error and recovers from by rendering that part again on the client. A different top in a style is worse still: React's docs say mismatched attributes are not guaranteed to be patched, so a box could stay at the server's position. The SSR lesson covers hydration in general.

Decision What does the server render for the grid?

Everything, in the server's zone

Place events with UTC on the server, correct them on the client.

Pros

  • Full HTML, good for the first paint.

Cons

  • Wrong times visible for the first second.
  • Hydration mismatch on every event.

Nothing on the server

Render the component only on the client.

Pros

  • No mismatch.

Cons

  • A blank area, then a 1,152 pixel grid appears and pushes content: layout shift and a slower Largest Contentful Paint.

The frame on the server, events after the zone is known

The server renders the toolbar, day headers, gutter and empty columns at their final size.

The client's first render matches that exactly. An effect then reads the zone and the second render places events and the now line.

Pros

  • No layout shift: the frame never changes size.
  • No mismatch and no wrong times.

Cons

  • Events appear one render after hydration, roughly a frame later.

A known zone, from the profile or a cookie

The host passes timeZone from the user's saved settings, or from a cookie that an earlier visit set from Intl.DateTimeFormat().resolvedOptions().timeZone.

Pros

  • Full, correct HTML on the server.

Cons

  • The first visit has no cookie.
  • A traveller's device zone may differ from the saved one; the client must check and re-render if needed.

What we pick: Use a known zone when the host has one, and otherwise render the frame on the server and events after mount. The day headers are safe to render on the server because the range is given as plain dates; the "today" highlight and the now line depend on the zone, so they wait for the client too. In React, useSyncExternalStore with a server snapshot of null is a clean way to say "no zone yet" during hydration.

Three frames of a server-rendered week view. First, the server HTML: toolbar, headers, gutter and empty day columns at their final size, because the server has no time zone. Second, hydration: the client's first render produces exactly the same frame, so there is no mismatch. Third, after an effect reads the time zone: events are placed and the now line appears. The frame never changes size.server HTMLframe onlyno zone yethydratesame framematches the HTMLafter effectevents placedzone known
The grid's size is fixed from the first byte, so placing events later causes no layout shift. Only zone-dependent parts wait: event positions, the today highlight and the now line.

Check your understanding · Exercise 10

Which of these can the server render correctly without knowing the user's time zone (select all that apply)? Select all that apply.

Architecture, data model and testing: a pure core, a thin shell

We now have all the pieces. The architecture question is where to draw the lines between them, and the answer also decides how we test. The rule we follow: everything that can be a pure function of props is one, so it can be memoized and tested with fixed dates and zones; state lives in as few places as possible; and only two parts touch the DOM in a hot path, the interaction controller and the now line.

Five pure parts, three small stateful ones, and the views.
PartResponsibilityKind
Range modelFrom view, date, week start and zone, the visible days and their [dayStart, dayEnd) instantsPure function
Time scaleOne step map per day column; yOf and instantAt; cached by zone and datePure, cached
SegmenterClips events to columns, splits at midnight, sends long events to the all-day rowPure
Layout engineOverlap columns per day; lanes for the all-day rowPure, memoized per column
Pending layerOptimistic overrides and drafts, with versionsComponent state
Interaction controllerPointer and keyboard state machine, ghost, auto-scroll, snappingRefs plus one small state for the ghost
ClockMinute-aligned now, paused when hiddenHook, subscribed by two parts only
AnnouncerOne polite live region with queued, short messagesComponent
ViewsToolbar slot, headers, all-day row, gutter, day columns, event boxes, list viewReact components with renderEvent and renderSlot slots
Architecture of the week view component. Outside it, the host app sends events in and receives edits out. Inside, the Range model feeds the Time scale, which feeds the Segmenter. Events from the host go into the Pending layer, then the Segmenter, then the Layout engine, which gives boxes to the Views. The Controller, which also uses the Time scale, sends a ghost to the Views, commits overrides to the Pending layer, calls the host's callbacks, and writes to the Announcer. The Clock, on the right, drives the now line in the Views.Host appfetch, saveevents in,edits outWeekView componentRange modelTime scaleClockminute tickPendingSegmenterLayoutControllerViewsheaders, gutter, columns, boxesAnnouncerboxescommit
The top two rows are pure functions of props, memoized and testable with fixed zones. Only the Controller and the Clock change things on their own, and only the Controller talks back to the host.

Why these boundaries? The time scale is separate from the segmenter because the controller, the now line and the keyboard all need instantAt without segments. The layout engine is separate from the views so the list view and the time grid share it, and the pending layer sits between the host's events and the segmenter, so a pending move is laid out exactly like a real one, and dropping the override is all a rollback needs. The host is outside the box entirely: it fetches, caches and saves.

The data model inside the component

ts
type Day = { date: string; dayStart: number; dayEnd: number; steps: Step[] | null };
// steps is null on normal days: the map is a straight line

type EventSegment = {
  key: string;            // eventId + date
  eventId: string;
  dayIndex: number;
  start: number; end: number;          // clipped instants
  startsBefore: boolean; endsAfter: boolean;
  top: number; height: number;         // px, from the step map, min 20
  col: number; cols: number; span: number;  // from the overlap layout
};

type AllDayBar = { eventId: string; firstDay: number; lastDay: number; lane: number };

type DragState =
  | { phase: "idle" }
  | { phase: "pressed" | "dragging"; kind: "create" | "move" | "resize-start" | "resize-end";
      eventId?: string; ghost: { dayIndex: number; start: number; end: number } }
  | { phase: "keyboard"; eventId: string; ghost: { dayIndex: number; start: number; end: number } };
Derived data. Only Pending and DragState are state; the rest is recomputed from props.
DataLives inWhy there
EventsHost (server cache), passed as propsThe host owns truth, auth and saving
View and dateHost, often the URLShareable links and back button; we only report changes
Days and step mapsMemo cache keyed by zone and datePure and reused across renders and weeks
Segments and layoutMemo per columnRecomputed only for columns whose events changed
Pending overridesComponent stateMust survive re-renders, cleared by promise results
Pointer coordinatesA refChange 60+ times a second and must not cause renders
Ghost positionSmall state inside the ghost componentChanges only when the snapped time changes
Scroll positionThe scroller element, saved in a ref on range changeKept when moving between weeks
Data flow. Read path along the top: the API server returns JSON for GET with from and to, into the host's cache; the host passes events as props into the pending layer, which merges overrides; the layout turns them into segments, and the views draw boxes with top and height. Write path along the bottom: a drag in the views goes to the controller, which writes an override into the pending layer and calls onMove on the host; the host sends PATCH to the API server and updates its cache.APIGET from, toHost cacheserver statePendingoverridesLayoutsegmentsJSONeventsmergedViewsevent boxesControllerdrop 11:00Hostmutationtop,heightdragoverrideonMoveupdatePATCHread: API, cache, props, overrides, layout, boxeswrite: drag, override, onMove, PATCH, cache update
Only two things cross into the component: events in and change callbacks out. The override goes up into the read path, so a pending move is drawn exactly like a saved one.

Check your understanding · Exercise 11

Which placements are right (select all that apply)? Select all that apply.

Testing the days that only come twice a year

Most calendar bugs hide in dates nobody tests by hand: the spring forward Sunday, the fall back Sunday, the last day of a year, a zone with a 45 minute offset. Because the core is the pure functions above, taking a zone name and instants, we can test those days on any machine on any date, without waiting for March.

  • Table tests for the time scale: for each of America/New_York, Europe/Berlin, Asia/Kolkata, Asia/Kathmandu (UTC plus 5:45) and Australia/Lord_Howe (30 minute shift), build every column of a transition week and check that yOf always increases, that instantAt(yOf(t)) returns t, and that gap rows map to nothing.
  • Segment tests: an event across midnight, one across the spring gap, one of exactly 24 hours, one ending at the start of a day (it must not create an empty segment on that day, because end is exclusive).
  • A fixed clock: fake timers set Date.now() to 23:59:30 to check that the now line and the today highlight move at midnight, and to 01:30 on the fall back night, once in each pass of the repeated hour.
  • Browser tests in many zones: Playwright's timezoneId and locale options run the same drag test in America/New_York with en-US and Asia/Jerusalem with he-IL, which also covers right-to-left and a Sunday week start.
  • Accessibility checks: an automated scan for names and roles, plus a scripted keyboard run that moves an event and asserts the live region text.

In production we log one cheap signal per failed save (event id, kind of change, error reason) and the time the drop took to paint. A spike of failed moves after a release usually means a contract change in a host, not a bug in the grid, and the logs tell those apart.

Think first

Why is "run the test suite with the CI machine's TZ set to a different zone" not enough to catch DST bugs in this component?

Running it in the interview

A component question gives you about 60 minutes. The interviewer wants to see that you treat it as a reusable piece with a contract, that you know where time is tricky, and that you can make gestures fast and accessible. Here is a plan that leaves room for their follow-ups.

MinutesDoDraw or say
0 to 5Scope questions: who embeds it, who owns data, which views, zone, touch, SSRThe requirements table, short
5 to 8Name the five hard properties"Time is not a straight line, the host owns truth, gestures are fast, space is shared, settings vary."
8 to 18API: event shape with instants and plain dates, controlled range, render slots, promise callbacksThe props type and the edit contract options
18 to 28Coordinate math: per-column map, DST gap and repeated hour, midnight split, all-day lanesThree columns: normal, 23 hours, 25 hours
28 to 40Interactions: controller, threshold, capture, long press, snapping, auto-scroll, Escape, pending layer and rollbackThe drag state machine and the move sequence with its failure path
40 to 50Performance and accessibility: absolute boxes, render window, drag hot path, keyboard model, announcementsThe architecture diagram
50 to 60Locales, RTL, SSR, testing, and the interviewer's questionsThe SSR timeline if asked

Draw the wireframe first and keep it on the board; every later topic points at a part of it. Say the numbers out loud: 48 pixels per hour, 15 minute snap, 5 pixel threshold, 250 ms long press, 300 events, 16 ms per frame. Numbers turn opinions into a design.

Follow-up questions to expect

  • "Show a second time zone in the gutter." A second gutter column formats each row's instant in the other zone. Rows are wall-clock rows of the grid's zone, so the second gutter can show uneven steps on DST days, which is honest. Placement does not change at all.
  • "A team wants a resource view: rooms as columns for one day." The time scale, segmenter and controller don't care what a column means. Columns become resources with the same [dayStart, dayEnd), and events are grouped by resource instead of by day. With 200 rooms, virtualize the columns horizontally.
  • "How do you show 40 overlapping events at 9:00?" Cap the sub-columns at about 4 per cluster and show a "+36" chip that opens a list. Boxes narrower than about 40 pixels are unreadable and untappable.
  • "Another user moves the event while I am dragging it." The host's events change mid-drag. We keep the drag going on the old times, and on drop the host decides: save, or reject with a conflict reason that we announce. If the event is deleted, the drag cancels.
  • "Why not use Date and the device zone everywhere?" Because the grid zone is a prop. A manager in London planning a New York team's week must see New York hours. Intl with an explicit timeZone and instants as numbers keep the device out of it.
  • "How do you support undo?" The host owns it: each settled change is an action it can reverse with another onMove. The undo and redo guide covers the command stack; the grid just exposes the changes.

Summary

  • Design the component's contract first: timed events as instants, all-day events as plain dates, a controlled range, render slots, and edit callbacks that return promises.
  • Every position comes from one per-column map in the grid's own time zone. On DST days it shows the gap as a hatched band and splits the repeated hour into two halves, so every instant has one place and the gutter stays aligned.
  • Cut events into segments per day before layout: cross-midnight events become linked pieces, events of 24 hours or more become all-day bars packed into lanes, and overlap layout runs on drawn heights.
  • Place boxes absolutely inside day columns, render the current range plus neighbors for swiping, and keep drags off the slow path: coalesce moves into one frame, move a ghost with transforms, lay out once on drop.
  • One pointer controller handles create, move and resize with capture, a 5 pixel threshold, long press on touch, wall-clock snapping, auto-scroll and Escape.
  • A pending layer of versioned overrides gives instant feedback and a clean rollback, while the host stays the source of truth.
  • The now line ticks once a minute on an aligned timer, sleeps in hidden tabs and reads the clock only after hydration; the grid opens at the current time or working hours without a visible jump.
  • Keyboard users get an event layer, a slot grid and a list view; screen readers hear day, time range and title, and every move and failure through one live region.
  • Week start, weekend, hour cycle and direction come from the locale; the server renders the frame and waits for a known time zone before placing events.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next