cf.completefrontend
My progress
SYSTEM DESIGN · #104

Design Google Calendar

A calendar web app with day, week, month, schedule and year views, many calendars, shared events and reminders. Boxes on a grid look easy; the hard parts are time zones and recurring events, laying out overlapping events, fetching only the visible range and keeping it fresh, and dragging events with snapping and safe rollback.

The question

Design the web app for Google Calendar. People see their week, create meetings, invite colleagues, and get a reminder before each one. Some have a weekly team meeting that has repeated for years, some travel across time zones, and some manage ten calendars at once.

Walk me through the views, how you model and fetch events, how overlapping meetings are drawn, how dragging an event works, and how the app handles time zones, recurring events, sharing, and reminders.

Real-life examples: Google Calendar on the web, Outlook calendar on the web, Apple Calendar on iCloud.com, Fantastical, Notion Calendar

Requirements: what to ask before drawing a grid

A calendar app shows events on a time grid. An event has a start, an end, a title, and usually a list of guests. People look at their calendar many times a day, mostly the current week, and they edit it a few times a day. That shape matters: reads are frequent and must be instant, writes are rare but must never be lost or silently wrong.

The scope of "design Google Calendar" is huge, so the first job is to narrow it. Each question below changes the design. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
Which views?Day, week, month, schedule (a list), and year. Week is the default and the most used.Each view needs a different time range and a different layout. The year view needs no event details at all.
Recurring events?Yes: daily, weekly, monthly, yearly, with exceptions like "skip the Christmas meeting".We need a recurrence rule format, a way to expand it into dates, and a way to edit one occurrence or the whole series.
Time zones?Yes. Users travel, guests live in other zones, and an event can have its own zone.Every timed event is stored as an instant plus a zone. The grid must handle days with 23 or 25 hours.
How many calendars per user?Typically 3 to 10 (own, team, holidays, a shared family calendar), up to about 50.Fetches are per calendar, each with a color and a visibility toggle.
How many events?A busy person has 30 to 60 events a week. A shared room or team calendar can have thousands a month.The week view is small. The schedule view across months and big shared calendars need ranged fetching and virtualization.
Collaboration?Invites, replies (yes, no, maybe), and calendars shared with others at different permission levels.Events change without the user doing anything, so we need a way to learn about changes from the server.
Reminders?Yes: a popup and an email before an event, even if the tab is closed.Reminders must be computed on the server, with the browser only showing them.
Offline?Read the recent weeks offline; editing offline is a stretch goal.A local cache of events in IndexedDB and a queue for writes.

Functional requirements

  • Show events in day, week, month, schedule, and year views, and move to the previous or next period, or jump to today.
  • Create an event by clicking or dragging on the grid, then fill in the title, time, guests, location, and a video meeting link.
  • Move and resize events by dragging, snapping to 15 minutes, with a keyboard way to do the same.
  • Create recurring events and edit "this event", "this and following events", or "all events" in the series.
  • Show several calendars at once, each in its own color, and turn each one on or off.
  • Invite guests, see their replies, and find a time when everyone is free.
  • Share a calendar so others see either only free and busy blocks, or the full details, or can edit.
  • Get reminders before events as a browser notification and by email.

Non-functional requirements

  • Fast views. Switching weeks should feel instant: under 100 ms when the range is cached, under about 500 ms on a cold fetch. The first load should show the current week within about 2 seconds on a mid-range laptop.
  • Correct times. A 9:00 meeting in New York must show at 15:00 in Berlin, on the right day, every week of the year, including the weeks when only one of the two places has changed its clocks.
  • Smooth dragging. Moving an event must keep up with the pointer at 60 frames per second, so each frame has about 16 ms of budget.
  • Fresh data. A change made by a colleague or on the phone should show up within a few seconds while the tab is open.
  • Never lose a write. An edit either succeeds, or the user sees clearly that it failed and the event returns to where it was.
  • Accessible. Every view works with a keyboard and a screen reader, which is hard for a grid of time slots.
  • International. Week start day, 12 or 24 hour clock, date formats, right-to-left languages, and other calendar systems for labels.

Out of scope

We leave out the server's storage design, the email sending pipeline, room booking rules, the tasks feature, and the mobile apps. We also skip the date picker inside the event form: the date picker guide covers plain dates, typed input, and the popup in depth, and we will link to it rather than repeat it. We mention the video meeting link only as a field the server fills in; the video conferencing guide covers the call itself.

Check your understanding · Exercise 1

Which answers from the interviewer change the frontend design the most? Select all that apply.

What makes a calendar hard

A first sketch is a table: seven columns for days, 24 rows for hours, and a box for each event. That sketch hides five problems that drive every later decision. Name them early in the interview, because each one becomes a chapter.

  1. Time is not a number line. An event at 9:00 in New York is a different moment from 9:00 in Berlin. Twice a year a day has 23 or 25 hours. An all-day birthday is not a moment at all: it's a date, and it must stay on the same date wherever you travel.
  2. Events repeat, with exceptions. A weekly meeting is one rule, not 500 rows. But one Tuesday it moves to Wednesday, and one week it's cancelled, and from March on it moves to 10:00. The rule and its exceptions must be combined correctly for any range you look at.
  3. Events overlap. Three meetings at 10:00 must share the column width without hiding each other, and the result must be stable: an event shouldn't jump sideways when an unrelated one is added.
  4. Data is ranged and shared. Nobody loads every event they ever had. We fetch the visible week, keep nearby weeks warm, and merge in changes that other people make to shared calendars.
  5. Direct manipulation. Dragging an event to a new time must be smooth, snap to 15 minutes, work near the edge of the scrolling area, and roll back cleanly if the server refuses.

Here is the main screen we'll design. The week view is a fixed header with the days, an all-day row for date-only events, and a scrolling time grid. On the left, a side panel holds a small month calendar and the list of calendars with their colors and checkboxes.

Wireframe of the calendar's week view for 9 to 13 March 2026, weekends hidden. A toolbar has a Today button, previous and next arrows, the heading March 2026, and a Week view switcher. A side panel on the left holds a mini month and a list of calendars: Ana and Team are checked, Holidays is not. The main area has a header with the days Monday 9 to Friday 13, an all-day strip with a Conference bar from Tuesday to Thursday, and a scrolling time grid from 9:00 to 14:00. Monday has Sync from 10:00 to 11:00. Wednesday has two overlapping events, A and B, side by side. A red now line crosses Tuesday at 12:20.Calendar: week of 9 March 2026Today<>March 2026Weekmini monthcalendarsxAnaxTeamHolidaysMo 9Tu 10We 11Th 12Fr 13Conference9:0010:0011:0012:0013:00SyncABnowA, B overlap:half each
Two kinds of event, two places: date-only events are bars in the all-day strip, timed events are boxes in the grid whose top is the start and whose height is the length. The grid scrolls; the header and the strip stay put.

Notice the two kinds of event in the picture. Timed events sit in the grid at a height proportional to their length. All-day and multi-day events sit in the strip above the grid as horizontal bars. They come from different fields of the event and have different rules, and mixing them up is the most common calendar bug.

The views and what each one needs from the data

Each view is a different question asked of the same data. If we know which time range and which fields each view needs, the data layer becomes one function: give me the event instances that touch this range, for these calendars. The views only differ in how they lay out the result.

ViewRange it showsWhat it needs per eventLayout problem
DayOne day, 00:00 to 24:00 in the viewer's zoneStart, end, title, color, location, guests' reply statusOverlap columns inside one day column. Lots of room, so more text per event.
Week7 days (or 5 with weekends hidden), from the locale's first day of the weekStart, end, title, colorOverlap columns in 7 day columns, plus multi-day bars in the all-day strip.
MonthThe 4 to 6 weeks that cover the month, so 28 to 42 days including the edges of the previous and next monthsStart date, title, color, and whether it's all-dayBars that span days and weeks, a row height limit, and "+3 more" overflow.
ScheduleFrom today forward, loaded as you scroll; can reach monthsStart, end, title, calendar, locationA long list grouped by day. Needs virtualization.
Year12 monthsOnly which days have events, or nothing at all12 small month grids. Google's year view shows mostly just the dates; at most it needs a per-day count.

Two things stand out. First, the range for a view is always computed in the viewer's time zone. The week of 9 March for a user in Tokyo starts at a different moment than the same week for a user in London.

So the client turns the view into a pair of instants, timeMin and timeMax, and asks the server for everything that overlaps them. An event overlaps the range when it starts before timeMax and ends after timeMin, so a meeting that started on Sunday night and runs past midnight shows up in Monday's view too.

Second, the views share ranges. The month of March contains the week of 9 March. If we cache by fixed blocks (say one block per week, keyed by the week's start date in the viewer's zone), the month view is just 5 or 6 week blocks, and switching from month to week costs nothing.

The day view reads one seventh of a cached week. This is why the cache key is a week block rather than "whatever range the view asked for".

Seven week blocks from W09 starting 23 February to W15 starting 6 April 2026, with weeks starting on Monday. The day view for Tuesday 10 March reads part of block W11. The week view reads all of W11. The month view for March reads six blocks, W09 to W14, because the grid shows 23 February to 5 April. The schedule view reads W11 onward as the user scrolls. The year view needs only a count of events per day.W09Feb 23W10Mar 2W11Mar 9W12Mar 16W13Mar 23W14Mar 30W15Apr 6DayTuesday only, from W11WeekW11Month6 blocks: Feb 23 to Apr 5ScheduleW11 on, as you scrollYearno events, just a count per day (or nothing)
Cache by week block, not by whatever range a view asked for. Then month, week, and day views share the same cached blocks, and switching between them costs no request.

The view lives in the URL

The current view and date belong in the URL, for example /calendar/week/2026/3/9. That makes reload, the back button, bookmarks, and sharing a link all work for free. Google Calendar's own web URLs follow this shape (/r/week/2026/3/9). The selected calendars, on the other hand, are a user setting stored on the server, because people expect the same checkboxes on every device.

Check your understanding · Exercise 2

A user in Sydney opens the month view for March. Which statements are true? Select all that apply.

The event model: instants, plain dates, and whose time zone

Before any grid, decide what an event's start and end are. This decision is invisible in a demo and decides whether the app is right for people who travel, people with guests abroad, and everyone in the weeks around a clock change.

Three ideas, explained once. An instant is a single moment in time, the same everywhere on Earth, like 2026-03-09T14:00:00Z (the Z means UTC). A wall-clock time is what a clock on the wall shows, like "9:00", and means nothing without a place.

A time zone is a named region with rules for turning instants into wall-clock times, like America/New_York, including when it switches to and from daylight saving time (DST). The date picker guide explains plain dates and instants in depth; here we apply them to events.

Decision How should an event store its start and end?

Wall-clock time only, no zone

Store 2026-03-09 09:00 as typed.

Every viewer reads it as their own local time.

Pros

  • Simple. Nothing to convert.

Cons

  • A call at 9:00 in New York shows at 9:00 in Berlin too, six hours wrong.
  • No way to tell when a reminder should fire.

UTC instant only

Store 2026-03-09T14:00:00Z.

Each viewer converts it to their zone for display.

Pros

  • Every viewer sees the right moment.
  • Sorting and overlap checks are simple number comparisons.

Cons

  • Loses the organizer's intent. A weekly 9:00 meeting in New York, stored as 14:00 UTC, drifts to 10:00 New York time after the clocks change.
  • Can't represent an all-day event without picking a fake midnight.

Instant plus the event's own zone, and plain dates for all-day events

Timed events store the instant and a zone name: { dateTime, timeZone }.

All-day events store plain dates: { date: "2026-03-09" }, with an exclusive end date.

Recurrence rules are expanded in the event's own zone.

Pros

  • Right moment for every viewer, and right wall-clock time for the organizer across DST.
  • Birthdays stay on their date anywhere in the world.

Cons

  • Two shapes of start and end that all code must handle.
  • The client needs time zone rules (the browser's Intl data or a library) to expand and display.

What we pick: Use the third option. It's also what the Google Calendar API does: each event's start and end hold either dateTime (an RFC 3339 timestamp with an offset) plus an optional timeZone, or date for an all-day event, and the API requires timeZone on recurring events so the rule can be expanded in the right zone. The extra shape is the price of being correct.

The event's zone versus the viewer's zone

Every timed event has two zones in play. The event's zone says where its wall-clock time is anchored. The viewer's zone is the zone of the person looking, from their settings or the device. Display always uses the viewer's zone.

The event's zone only matters for two things: expanding recurrence ("every Monday at 9:00" means 9:00 in New York), and showing a hint in the event details like "9:00 to 10:00 (Eastern Time)" when it differs from the viewer's.

A worked example. Ana in New York creates "Standup, every Monday 9:00 to 9:15, America/New_York". Ben is in Berlin. In January New York is UTC-5 and Berlin UTC+1, a 6-hour gap, so Ben sees 15:00.

On 8 March 2026 the US moves its clocks forward; Europe doesn't until 29 March. For those three weeks the gap is 5 hours, and Ben sees the standup at 14:00. After 29 March it's back to 15:00.

Nothing changed in the data. The grid is right only because we expanded the rule in New York's zone and then converted each instance to Berlin's.

A standup every Monday at 9:00 in America/New_York, seen from Berlin. In the week of 2 March it is 9:00 EST in New York, 14:00 UTC, and 15:00 in Berlin. In the week of 9 March it is 9:00 EDT, 13:00 UTC, and 14:00 in Berlin, because the US moved its clocks on 8 March and Europe only on 29 March. In the week of 30 March it is 9:00 EDT, 13:00 UTC, and 15:00 CEST. Below, an all-day holiday stored as the plain date 2026-12-25 shows on the same date in every zone.Standup: Mon 9:00, America/New_York, FREQ=WEEKLYNew YorkUTCBerlinMar 29:00 EST14:00Z15:00 CETMar 99:00 EDT13:00Z14:00 CETMar 309:00 EDT13:00Z15:00 CESTMar 8: US clocks move. Mar 29: Europe's move.For three weeks Ben in Berlin sees 14:00, not 15:00.Holidaydate: 2026-12-25same date in every zone
The rule is expanded in the event's zone, so New York always reads 9:00; the UTC instant and Berlin's view move. An all-day event has no instant at all, only a date.

All-day events are dates, not moments

An all-day event, like a holiday or a three-day conference, has no time and no zone. It's a range of plain dates, and by the iCalendar convention the end is exclusive: a one-day event on 9 March is { date: "2026-03-09" } to { date: "2026-03-10" }. A three-day event from 9 to 11 March ends on 12 March.

Exclusive ends make the length simple (end minus start in days) and make back-to-back ranges never overlap. The UI shows the end as "11 March", so the display code subtracts a day; that's the one place to be careful.

Never turn an all-day event into midnight UTC. If you do, a user in California sees it starting at 16:00 the day before, and the bar lands on the wrong date. Keep it as a date all the way to the screen, and place it on the day with the same calendar date in the viewer's grid.

The iCalendar vocabulary

Calendars talk to each other with iCalendar, the format defined in RFC 5545 (the .ics files you import and the invites you get by email). Even if your API is JSON, its words are worth knowing because Google, Outlook, and Apple all use them:

TermWhat it meansExample
DTSTARTThe first start, with its zoneDTSTART;TZID=America/New_York:20260105T090000
RRULEThe repeat ruleRRULE:FREQ=WEEKLY;BYDAY=MO
EXDATEStarts to skip (a cancelled occurrence)EXDATE;TZID=America/New_York:20260216T090000
RDATEExtra starts added to the ruleOne extra session on a Saturday
RECURRENCE-IDOn an exception: which original occurrence it replacesThe 2 March standup moved to 10:00
UIDThe series' identity across systemsShared by the master and its exceptions
SEQUENCEA revision number for invitesRaised when the time changes, so guests' apps update

In the Google Calendar API these map closely. A recurring event has a recurrence array of strings such as RRULE:FREQ=WEEKLY;BYDAY=MO and EXDATE lines. An occurrence that was changed is a separate event (an exception) with recurringEventId pointing to the series and originalStartTime saying which occurrence it replaces, like RECURRENCE-ID.

A cancelled occurrence is an exception with status: "cancelled". Every event carries an etag, a version string we'll use to detect edit conflicts.

Check your understanding · Exercise 3

Which of these are bugs waiting to happen? Select all that apply.

Recurring events: expanding the rule and editing a series

A weekly standup that has run for three years is about 150 occurrences. A daily reminder with no end is infinite. So a recurring event is stored once, as a master (the first start, the rule, and the shared details) plus a few exceptions. Somebody has to turn that into the concrete instances that the week view draws, and the question is who does it, and where.

Decision Where do recurring events get expanded into instances?

Server expands, client gets instances

The client asks for a time range and the server returns every instance in it, one object each.

In the Google API this is events.list with singleEvents=true and timeMin/timeMax, or events.instances for one series.

Pros

  • One expansion engine, on the server, tested once.
  • The client stays simple: an instance is just an event with a start and end.
  • Free/busy viewers can only ever see instances anyway.

Cons

  • Every range fetch repeats the series' details for each instance.
  • Offline, the client can't show ranges it never fetched.
  • A change to the series invalidates every cached range that contains it.

Client expands masters

The client fetches masters and exceptions (singleEvents=false) and runs an RRULE library such as rrule.js for the visible range.

Exceptions replace or cancel the matching occurrence.

Pros

  • Any range can be drawn offline from the masters in IndexedDB.
  • Editing a series updates every view at once, because there's only one object.

Cons

  • A second expansion engine that must agree with the server's, including time zones and odd rules like BYSETPOS.
  • A master from 2019 must be fetched to draw this week, so range queries need a "recurring masters that may touch this range" index.

Hybrid: server instances, client expansion for previews

Views render server instances.

The client expands locally only for instant feedback: the preview in the repeat dialog, and the optimistic update after a series edit until the server's instances arrive.

Pros

  • Server stays the source of truth for what's drawn.
  • Edits still feel instant.

Cons

  • Two code paths; the local preview may briefly disagree with the server on exotic rules.

What we pick: Pick the hybrid. Rendering server-expanded instances keeps one source of truth and matches how Google's API and its free/busy rules work. The client still ships a small expansion function for previews and optimistic edits, and for offline reads if we choose to cache masters. Say out loud that the two must agree, and that the server wins on refresh.

Expanding a rule correctly

Expansion walks the rule in the event's zone, in calendar terms, and only then converts each start to an instant. "Weekly on Monday at 9:00 in New York" produces 9:00 New York every Monday, whatever UTC offset that day has. Then it removes EXDATE starts, swaps in exceptions by their original start, and keeps the instances that overlap the range. The code below uses Temporal, which shipped in Firefox 139 (May 2025) and Chrome 144 (January 2026) but still needs a polyfill in Safari, and handles only FREQ=WEEKLY to show the idea.

ts
function expandWeekly(m: Master, range: { from: Temporal.Instant; to: Temporal.Instant }) {
  const out: Instance[] = [];
  let start = Temporal.ZonedDateTime.from(m.dtstart); // carries m.timeZone
  const len = m.duration;                              // e.g. { minutes: 15 }
  for (let i = 0; i < (m.count ?? Infinity); i++, start = start.add({ weeks: 1 })) {
    if (m.until && Temporal.Instant.compare(start.toInstant(), m.until) > 0) break;
    const s = start.toInstant();
    if (Temporal.Instant.compare(s, range.to) >= 0) break;
    const key = s.toString();                  // originalStartTime of this occurrence
    if (m.exdates.has(key)) continue;          // cancelled
    const exc = m.exceptions.get(key);         // moved or edited occurrence
    const inst = exc ?? { id: `${m.id}_${key}`, start: s, end: start.add(len).toInstant() };
    if (Temporal.Instant.compare(inst.end, range.from) > 0) out.push(inst);
  }
  return out;
}
start.add({ weeks: 1 }) adds a calendar week in the event's zone, so 9:00 stays 9:00 across DST. A real engine also scans moved exceptions whose new start lands in the range.

Two details in that code matter in interviews. The loop adds a calendar week, not 604,800,000 milliseconds, which is what keeps the wall-clock time stable across DST. And exceptions are matched by the original start, not the new one: if the 2 March standup was moved to Tuesday, the engine must skip Monday's occurrence and draw Tuesday's exception instead. The lab below lets you watch both.

Expand by
Viewer in
Exceptions
This and following at 10:00 from
Weekly standup, Mondays 9:00 America/New_York, 6 occurrences from 2 March 2026. Mar 2: New York 9:00, 14:00Z, Berlin Mon 15:00, series, ok; Mar 9: New York 9:00, 13:00Z, Berlin Mon 14:00, series, ok; Mar 16: New York cancelled, EXDATE, Berlin none, series, cancelled by EXDATE; Mar 23: New York Tue 10:00, 14:00Z, Berlin Tue 15:00, series, moved: exception matched by original start; Mar 30: New York 9:00, 13:00Z, Berlin Mon 15:00, series, ok; Apr 6: New York 9:00, 13:00Z, Berlin Mon 15:00, series, ok.New YorkUTCBerlinseriesMar 29:0014:00ZMon 15:00seriesMar 99:0013:00ZMon 14:00seriesMar 16cancelledEXDATEseriesMar 23Tue 10:0014:00ZTue 15:00seriesMar 309:0013:00ZMon 15:00seriesApr 69:0013:00ZMon 15:00seriesDST: New York Mar 8, Berlin Mar 29, Tokyo none
Expanded in New York's zone, every occurrence stays at 9:00 there, while Berlin sees the time shift around the two DST dates.

Editing: this event, this and following, or all events

When you change a recurring event, Google Calendar asks which occurrences to change. Each choice is a different write:

ChoiceWhat the client sendsWhat happens to existing exceptions
This eventCreate or update an exception for this occurrence: an event with recurringEventId and originalStartTime, and the new fields.Only this one changes. Other exceptions stay.
This and following eventsSplit the series: end the old master just before this occurrence (UNTIL), and create a new master starting here with the new fields.Exceptions after the split point belong to the old series and are usually dropped or moved. The UI should warn when there are some.
All eventsUpdate the master. If the time changed, every instance moves.Field changes like the title usually carry to exceptions; time changes may reset them. Decide and tell the user.

"This and following" is the interesting one because one click produces two writes that must both happen. If the client sends them separately and the second fails, the user is left with a series that stops and never continues. So the client sends one request, for example POST /events/{id}/split with the split point and the new fields, and the server does both changes in one transaction.

The public Google API has no single split call: a client updates the old master's recurrence and inserts a new event, two requests. That's one reason a product's own backend for frontend (BFF) wraps the pair into one call.

A weekly Monday 9:00 series from 16 February to 6 April 2026. Before the edit, the 2 March occurrence was moved to Tuesday 3 March as an exception, and the 23 March occurrence was cancelled. The user changes the 16 March occurrence to 10:00 and picks this and following events. After the split, the old series ends with an UNTIL just before 16 March and keeps its occurrences from 16 February to 9 March, including the moved one. A new series starts on 16 March at 10:00 and runs to 6 April. The cancellation of 23 March belonged to the old series and is dropped.split herebefore9:00Feb 169:00Feb 23Tue 3Mar 29:00Mar 99:00Mar 16xMar 239:00Mar 309:00Apr 6old9:009:00Tue 39:00UNTIL before Mar 16new10:0010:0010:0010:00new master from hereMar 23's cancellation was an exception of the old series:it no longer applies, so the server drops it and warns.
One click, two changes: the old master gets an UNTIL just before the split occurrence's original start, and a new master begins there. The server does both in one transaction.

Think first

A user deletes "this and following" from the 9th occurrence of a weekly series with COUNT=20. What should the old master look like afterwards?

Check your understanding · Exercise 4

Which statements about recurring events are correct? Select all that apply.

Fetching by range, caching neighbors, and staying in sync

The naive approach: when the user opens a week, call GET /events?from=...&to=..., store the result in component state, and refetch every time they press "next" or "previous". It works in a demo and fails in three ways.

Going back to last week refetches what we had a second ago, so every click shows a spinner. Pressing "next" five times quickly fires five requests, and if they return out of order the grid can show the wrong week (the request races lesson covers this failure). And a colleague's change never shows up until the user happens to navigate.

Cache by week block, prefetch the neighbors

Split time into week blocks in the viewer's zone, and key the cache by calendar and block: ['events', calendarId, '2026-W11'], where the label names the week that starts on the user's first day of the week (here Monday 9 March). A view asks for the blocks it covers: one for day and week, 4 to 6 for month. Each block is fetched once, then served from memory.

After the visible blocks load, the client prefetches the previous and next block in idle time (requestIdleCallback or a low-priority fetch with priority: 'low'), so the next click is instant. A small LRU (least recently used) limit, for example 26 blocks per calendar, keeps memory bounded: half a year of a busy calendar is about 1,500 instances at roughly 1 KB each.

Why per calendar? Because calendars are toggled on and off, shared with different permissions, and change at different rates. Turning off "Holidays" should hide its events instantly without a request, and turning it back on should show cached data while revalidating. Each calendar in the list carries its color (backgroundColor and foregroundColor in Google's calendarList entries) and a selected flag; the view filters instances by the selected set and paints them with the calendar's color unless the event has its own colorId.

With 8 visible calendars, a week costs 8 requests. That's fine over HTTP/2, where they share one connection, but a BFF endpoint that takes a list of calendar ids and returns one combined response is better on slow phones: one round trip, one loading state, and one place to apply permission rules.

Data flow for the week view. On the server side, the calendar service with its events and change log feeds the backend for frontend, which serves range reads, change lists, and writes. In the browser, the sync engine receives JSON events for week blocks and merges them into the event store, which keeps instances by id and a copy in IndexedDB. The store gives instances to the week view, which lays them out and renders. When the user drops an event, the view hands the change to the mutation queue, which sends a PATCH with If-Match to the backend and updates the store optimistically. When anything changes on the server, the backend sends a small nudge over the WebSocket, and the sync engine runs a delta sync.ServerBrowserBFFrange, changesCalendarevents, logSync engineblocks, cursorsSocketnudges onlyMutation queueIf-Match, outboxEvent storeby id, IDB copyWeek viewlayout, renderJSONnudgedelta syncmergeinstancesdropPATCHqueue puts anoptimistic copyin the store
Reads go from the BFF through the sync engine into the store, writes go back through one queue, and the socket carries only nudges. Every arrow from the server lands in the sync engine or its socket, never directly in a component.

The life of a cached block

Each block moves through a small state machine. Empty blocks have never been fetched. Loading blocks have a request in flight, with an AbortController so a block the user scrolled away from can be cancelled. Fresh blocks are drawn as they are.

Stale blocks are still drawn, but a background refetch is due: the server told us something changed, or the block is older than a few minutes and the tab regained focus. Error keeps the old data if there was any and shows a quiet retry message. The rule that matters: stale data is drawn immediately and replaced when fresh data arrives. A calendar that flashes a spinner on every focus feels broken.

State machine of a cached week block. Empty goes to Loading when a view needs the block. Loading goes back to Empty when the request is aborted because the user moved away, to Fresh on a 200 response, and to Error on a failure. Fresh goes to Stale on a nudge, on window focus after a few minutes, or by age. Stale goes to Loading for a background refetch. Error goes to Loading on retry. Stale and Error blocks keep drawing their old data.Emptynever fetchedLoadingAbortControllerFreshdrawn as isErrorold data + retryStaledrawn, refetchingneededabort200failretrynudge,focus, agerefetchStale and Errorstill draw old data
Only Empty and Loading with no old data show a skeleton. Stale and Error keep drawing the last good copy, so focus and reconnects never flash a spinner.

Incremental sync: ask only for what changed

Decision How does an open tab learn that a colleague moved a meeting?

Poll the visible range

Refetch the visible blocks every 60 seconds and on window focus.

Pros

  • Trivial to build. Works through any proxy.

Cons

  • Up to a minute late.
  • Downloads the whole week each time even when nothing changed: with 10 million open tabs that's about 170,000 requests a second for nothing.

Delta sync with a cursor

The server keeps a change log per calendar. The client stores a cursor and asks "what changed since this cursor?".

The answer lists changed and deleted events plus a new cursor.

Pros

  • Responses are tiny when nothing changed.
  • Deletions are reported, which a range refetch can only infer.

Cons

  • Still needs a trigger: polling the delta endpoint or a push.

Push a nudge, then delta sync

Keep a WebSocket or Server-Sent Events (SSE) connection open while the tab is visible.

The server sends a small "calendar X changed" message; the client runs a delta sync for that calendar.

Pros

  • Changes appear in a second or two.
  • The push channel carries no event data, so permissions are checked in one place: the delta endpoint.

Cons

  • A connection per open tab, with reconnect logic.
  • Messages can be missed during a reconnect, so the client must sync on every reconnect anyway.

What we pick: Use push nudges plus delta sync, with a slow poll (every few minutes) and a sync on focus and on reconnect as a safety net. It's the same pattern as the Slack guide's reconnect flow: the push channel says "something changed", the delta endpoint says what.

Google's public API has exactly these two pieces. Incremental sync: the first events.list response's last page carries a nextSyncToken; later calls pass syncToken and get only changed events, including deleted ones (with status: "cancelled"). If the token expired or permissions changed, the server answers 410 Gone and the client must wipe its copy and do a full sync. A sync token can't be combined with most filters, including timeMin and timeMax, so a sync covers the whole calendar, not a range.

Push notifications: events.watch registers a channel of type web_hook with an HTTPS address, and Google calls that server URL when the calendar changes. The call carries headers like X-Goog-Resource-State, not the changed event, so the receiver still runs an incremental sync.

A browser tab can't receive a webhook, because it has no public URL. So in our design the webhook (or the calendar service's internal change stream) goes to our backend, which fans a nudge out over the tab's WebSocket. The tab then calls our delta endpoint with its cursor. The sequence below shows the whole path, including what the client does with the result.

Sequence diagram of incremental sync between the week view, the sync engine, the backend for frontend, and the calendar service. The calendar service tells the BFF that calendar cal42 changed. The BFF sends a nudge over the WebSocket to the sync engine. The sync engine asks for changes since its cursor. The BFF replies with the changed and deleted events and a new cursor. The sync engine merges them into the store and the week view re-renders. After a socket reconnect the sync engine runs the same delta request without waiting for a nudge. If the cursor has expired, the BFF answers 410 and the client wipes that calendar and does a full sync.Week viewSync engineBFFCalendarcal42 changedWS nudge: cal42GET changes?cursorchanges + cursormerge, re-renderreconnect: delta410: full resync
The nudge carries no event data; the changes endpoint is the only place data and permissions come from. A 410 is not an error to retry: it means start over for that calendar.

Merging a delta into a range cache takes care. A changed event may now belong to a different block (it moved to next week), so the client removes it from its old block by id and inserts it into the new one. A change to a recurring master marks every cached block for that calendar as stale, since we can't cheaply tell which blocks its instances touch; the visible ones refetch at once.

A deleted event is removed everywhere. A change is ignored if the cached copy's updated time is newer, which happens when our own write raced the nudge. An etag can't decide this, because it only says that two versions differ, not which one is newer.

Check your understanding · Exercise 5

Which statements about keeping the calendar fresh are correct? Select all that apply.

Laying out overlapping events in the day and week grid

In the time grid, an event's top is its start and its height is its length: with 48 pixels per hour, a 30-minute meeting at 10:00 sits at top: 480px with height: 24px. The hard part is the horizontal axis. When events overlap, they must share the day column's width so none hides another, and the layout must be stable, readable, and fast enough to recompute for every day of the week on each change.

The naive approach gives every event the full width. Two meetings at 10:00 then sit on top of each other and one is invisible. The next naive approach divides the whole day by the most overlaps at any moment: if three meetings collide at 10:00, every event that day gets a third of the width, including a lone 16:00 meeting that collides with nothing. Overlap must be handled locally.

Decision How should overlapping events share a day column?

Equal columns per cluster

Group events that overlap directly or through a chain into a cluster.

Give each cluster as many columns as it needs, and every event in it the same width.

Pros

  • Simple and predictable. No event ever overlaps another.

Cons

  • Wastes space: a long event next to a short one stays narrow even after the short one ends.

Columns, then expand to fill

Same clusters and columns, then let each event grow into the columns to its right while nothing there overlaps it in time.

Pros

  • Uses the space. Most events end up wider and more readable.
  • Still no overlaps, so every title is visible.

Cons

  • A little more work: for each event, check the events in the columns to its right.

Cascade (stacked with an offset)

Draw later-starting events on top of earlier ones, each indented a little from the left, with a border so the edges show.

Pros

  • Each event gets most of the width.
  • Feels natural when events start at clearly different times.

Cons

  • Covers parts of other events, so their text and drag handles can be hidden.
  • Hard to click the covered event, and confusing for equal starts.

What we pick: Use columns with expand to fill as the base, because it never hides anything and it's easy to reason about. Google Calendar's week view appears to mix in a cascade, letting a later event partly cover an earlier one when their starts are far apart, which is a fine polish to mention. Whatever you pick, say that touching events (one ends at 10:00, the next starts at 10:00) do not overlap.

The algorithm, step by step

  1. Sort the day's timed instances by start time, and for equal starts, longest first. Longer events then take the leftmost columns, which reads better.
  2. Cluster. Walk the sorted list keeping the latest end seen so far. If the next event starts at or after that end, the current cluster is closed and a new one begins. Every event in a cluster overlaps at least one other through a chain.
  3. Assign columns inside each cluster greedily: put the event in the first column whose last event has already ended by this event's start; if none, open a new column. This is the classic interval partitioning greedy, and it uses the fewest columns possible.
  4. Width. Each event's base width is one column: 100% / columns of its cluster, and its left edge is column * width.
  5. Expand to fill. For each event, look at the columns to its right, one by one. If no event in that column overlaps it in time, widen it by one column and continue; stop at the first column that has a collision.
ts
type Ev = { id: string; start: number; end: number }; // minutes since local midnight
type Placed = Ev & { col: number; span: number; cols: number };
const overlaps = (a: Ev, b: Ev) => a.start < b.end && b.start < a.end;

export function layoutDay(events: Ev[]): Placed[] {
  const sorted = [...events].sort((a, b) => a.start - b.start || b.end - a.end || a.id.localeCompare(b.id));
  const out: Placed[] = [];
  let cluster: Placed[] = [], columns: Ev[][] = [], clusterEnd = -1;
  const close = () => {
    for (const p of cluster) {
      p.cols = columns.length;
      while (p.col + p.span < columns.length &&
             !columns[p.col + p.span].some((o) => overlaps(o, p))) p.span++;
    }
    out.push(...cluster); cluster = []; columns = [];
  };
  for (const ev of sorted) {
    if (ev.start >= clusterEnd) { close(); clusterEnd = ev.end; }
    let col = columns.findIndex((c) => c[c.length - 1].end <= ev.start);
    if (col === -1) { col = columns.length; columns.push([]); }
    columns[col].push(ev);
    cluster.push({ ...ev, col, span: 1, cols: 0 });
    clusterEnd = Math.max(clusterEnd, ev.end);
  }
  close();
  return out; // left = col / cols, width = span / cols
}
O(n log n) for the sort plus O(n times columns) for placing and expanding. A busy day has under 30 events, so this runs in well under a millisecond.

A worked example. A at 9:00 to 11:00, B at 9:30 to 10:00, C at 10:00 to 10:30, D at 13:00 to 14:00. Sorted: A, B, C, D.

A opens cluster 1 in column 0. B overlaps A, so column 1. C starts at 10:00: column 0's last event (A) ends at 11:00, still busy, but column 1's last (B) ended at 10:00, so C goes into column 1.

D starts at 13:00, after the cluster's end of 11:00, so cluster 1 closes with 2 columns and D starts cluster 2 alone with full width. Expansion changes nothing in cluster 1, because column 1 is the rightmost. A and B and C each get half the width, and D gets all of it.

The worked example drawn three ways, on a grid from 9:00 to 14:00. Events: A from 9:00 to 11:00, B from 9:30 to 10:00, C from 10:00 to 10:30, and D from 13:00 to 14:00. Full width: B and C cover A. Splitting the whole day by its maximum overlap of 2: A, B, and C share columns correctly, but the lone D is also squeezed to half width. Per cluster: A, B, and C form one cluster with 2 columns, with C reusing B's column because B ended at 10:00, and D is its own cluster at full width.9:0010:0011:0012:0013:0014:00full widthABCDsplit whole dayABCDper clusterABCDABC: 2 cols, D: 1D squeezed
Overlap is local. Cluster first, then count columns inside each cluster, so an event that touches nothing keeps the whole column. B and C touch at 10:00 and share a column.

Edge cases that break naive versions

  • Very short events. A 5-minute event is 4 pixels tall at 48 pixels per hour, too small to click. Draw a minimum height (say 20 pixels, about 25 minutes) and use that visual end in the overlap test too, or the next event will be drawn on top of it.
  • Events crossing midnight. Cut each instance into one segment per day it covers before layout. A 22:00 to 02:00 event becomes 22:00 to 24:00 on day one and 00:00 to 02:00 on day two, each laid out with its own day.
  • Stability. Sorting by start, then longest first, then by id makes the layout deterministic, so an event doesn't swap columns when the data refreshes in a different order.
  • Declined events. Many users hide them; if shown, they are usually drawn faded but still take part in the layout so nothing jumps when the user accepts.

Try it in the lab: add events, move them, and step through sort, cluster, columns, and expand to see each stage's output. It starts with a different day from the worked example, chosen so that the last step matters. A, B, and C all start at 9:00 and take columns 0, 1, and 2. D (10:00 to 11:00) reuses column 1 after B ends, then expands into column 2, which is free after C ends at 9:30.

Step
Event
A day column from 9:00 to 15:00 at step 4 expand. Events: A 9:00 to 11:00, cluster 1, column 0 of 3, span 1; B 9:00 to 10:00, cluster 1, column 1 of 3, span 1; C 9:00 to 9:30, cluster 1, column 2 of 3, span 1; D 10:00 to 11:00, cluster 1, column 1 of 3, span 2; E 13:00 to 14:00, cluster 2, column 0 of 1, span 1.9:0010:0011:0012:0013:0014:0015:00ABCDE4 expandA: col 0 of 3, span 1B: col 1 of 3, span 1C: col 2 of 3, span 1D: col 1 of 3, span 2E: col 0 of 1, span 1
Expanded: D grew into free columns to the right.

Check your understanding · Exercise 6

Events: A 9:00 to 10:00, B 9:00 to 9:30, C 9:30 to 10:00, E 10:00 to 11:00. Which statements are true for columns plus expand? Select all that apply.

Month view, all-day bars, and "+3 more"

The month view and the all-day strip of the week view share one problem: horizontal bars that span days, packed into rows of limited height. A three-day conference is one bar across three cells. A holiday is a one-cell bar. In the month view, timed events also become one-line entries like "10:00 Standup" inside their day cell.

Packing bars into lanes

Think of each week row as a set of horizontal lanes. Every bar must sit in one lane, across all the days it covers, and two bars in the same lane must not share a day. The packing is the same greedy as the time grid, turned sideways:

  1. Cut at week edges. A bar from Friday to Tuesday becomes two segments: Friday to Sunday in one week row, Monday to Tuesday in the next. Draw a notch or square corner where a segment continues, so the reader knows it does.
  2. Sort the week's segments: multi-day first, longest first, then all-day single days, then timed events by start time. Long bars at the top stay straight and easy to follow.
  3. Assign lanes: each segment takes the lowest lane that is free on every day it covers. Keep, per lane, a 7-slot array of which days are taken.
  4. Cut off at the visible limit. A cell has room for a fixed number of lanes, measured from its height. Anything in a deeper lane is hidden, and each day shows "+N more" with N counting that day's hidden items.
One week row of a month view, Monday 2 to Sunday 8 March, where each cell has room for 3 lines. Lane 1 holds a one-day Holiday on Monday, a Conference bar from Tuesday to Thursday, and a Trip bar from Friday to Sunday that continues into the next week. Lane 2 holds a Workshop bar from Wednesday to Friday. Wednesday also has two timed events, a 10:00 Standup and Launch, which would need lanes 3 and 4, so its third line shows plus 2 more. Notes list the steps: cut bars at week edges, sort long first, give each bar the lowest free lane, and cut off at the cell's limit.Mo 2Tu 3We 4Th 5Fr 6Sa 7Su 8HolidayConferenceTrip, continuesWorkshop+2 moreWednesday is full1 cut at week edges2 sort: long first3 lowest free lane4 cut at the limithidden on Wed:10:00 StandupLaunchcell fits 3 linesmeasured height
Each bar takes the lowest lane free on all its days, so it stays straight across the row. The +N more link uses a line itself, so a day with one item too many shows one fewer item and +2 more.

Two details make the overflow feel right. First, the "+N more" link takes a lane itself, so if a day has exactly as many items as lanes, show them all, but if it has one more, show one fewer and "+2 more". Showing "+1 more" in place of the one item that would fit wastes a click.

Second, the number of lanes comes from the real cell height, not a constant: a ResizeObserver on the grid gives the row height, divide by the line height (about 20 pixels) and subtract one for the date label. A tall window shows 5 lanes; a short laptop screen shows 2.

Clicking "+N more" opens a popover listing that day's items in full, with the same order. It's a dialog with focus moved into it, and Escape returns focus to the link. Clicking the day number itself goes to the day view, which has room for everything.

Which events go in the all-day strip?

Events with date (not dateTime) always do. Long timed events are a judgment call: a flight from 22:00 to 07:00 belongs in the grid, but a timed three-day hackathon would be one 72-hour column covering everything else. Many calendar apps, Google's included, move timed events of 24 hours or more to the all-day strip, drawn with their start time as a label. The rule must be the same in every view, or an event jumps between the strip and the grid when the user switches from week to day.

Remember the exclusive end. A one-day holiday 2026-03-09 to 2026-03-10 covers one cell, not two. Off-by-one bugs here show up as bars that spill into the next day, and they hide well in tests that only use multi-day events.

Check your understanding · Exercise 7

A month cell has room for 3 lines, and that day has 4 items. Which statements about drawing it are correct? Select all that apply.

Time zones and DST in the grid

The event model chapter decided what we store. This chapter is about drawing it: a grid with a row per hour, in the viewer's zone, on days that are not always 24 hours long. The date picker guide covers DST for plain dates; a calendar has the harder case of times inside those days.

The 23-hour day and the 25-hour day

In New York on 8 March 2026, clocks jump from 01:59 to 03:00. The hour from 02:00 to 03:00 doesn't exist, so the day is 23 hours long. On 1 November 2026 clocks go from 01:59 back to 01:00, so the hour from 01:00 to 02:00 happens twice and the day is 25 hours long. Europe changes on different Sundays (29 March and 25 October in 2026), and many countries don't change at all.

Decision How should the day column handle a 23 or 25 hour day?

Wall-clock rows

Always draw 24 rows labeled 00:00 to 23:00.

Place an event by its wall-clock start and end in the viewer's zone.

Pros

  • Every day column lines up across the week.
  • Matches how people read a schedule: "my meeting is at 9".

Cons

  • On the short day, the 02:00 row is an hour that never happens.
  • On the long day, two different hours share the 01:00 row, so their events overlap on screen.

Elapsed-time rows

Draw as many rows as the day really has: 23 or 25.

Place events by real minutes since midnight.

Pros

  • Exact: event heights always match real durations.

Cons

  • The day's rows don't line up with the rest of the week, so 9:00 is at a different height on Sunday than on Monday.
  • Strange to read for a change that happens at night.

What we pick: Use wall-clock rows, which is what Google Calendar and most calendar apps do. The changes happen at night, when few events exist, so the rare oddity is a good trade for a week where every 9:00 lines up. Handle the two edge cases below explicitly.

  • An event inside the repeated hour. A call from 01:30 EDT to 01:30 EST is 60 real minutes, but its wall-clock start and end are both 01:30. Placing by wall clock gives zero height. Rule: position the top by wall clock, and if the wall-clock end is not after the start, take the height from the real duration.
  • An event that crosses the change. A night shift from 22:00 to 06:00 on the long day is 9 real hours but 8 wall-clock hours. The bar shows 8 rows, while the details say "22:00 to 06:00 (9 hours)". On the short day, a 01:30 to 03:30 event is 2 rows tall but 1 real hour.
  • Day boundaries. The start of a day is the viewer's local midnight turned into an instant. Build it with a zone-aware API, never with + 24 * 60 * 60 * 1000, which drifts by an hour across the change. In some zones (historically in Brazil, for example) the change itself happened at midnight, so a day could start at 01:00. Temporal's startOfDay() handles that.
Two New York days in 2026 with wall-clock rows from 0:00 to 5:00. On 8 March the 2:00 row is hatched because clocks jump from 1:59 to 3:00, and an event from 1:30 to 3:30 is drawn two rows tall though it lasts one real hour. On 1 November the 1:00 row is hatched because that hour happens twice, and a call from 1:30 EDT to 1:30 EST has the same wall-clock start and end, so its height is taken from its real duration of 60 minutes.8 March: 23 hours0:001:002:003:004:002:00 never happens1 November: 25 hours0:001:002:003:004:001:00 happens twice1:30 to 3:301:30 call2 rows tall, 1 real hoursame wall clock: use 60 min
Every day keeps 24 rows so 9:00 lines up across the week. Position by wall clock; when the wall-clock end isn't after the start, take the height from the real duration instead of drawing nothing.

A secondary time zone column

Google Calendar lets you show a second set of hour labels for another zone, for people who work with a team abroad. The grid stays in the primary zone; only the labels change. For each row, take the instant at the start of that row in the primary zone and format it in the secondary zone with Intl.DateTimeFormat and its timeZone option.

That gives correct labels even when the secondary zone is 5 hours 30 minutes ahead (India) or 5 hours 45 minutes ahead (Nepal), where labels read "14:30" instead of a whole hour, and even in the weeks when only one of the two zones has changed its clocks.

Travel and the device's zone

The browser reports its zone with Intl.DateTimeFormat().resolvedOptions().timeZone. When that differs from the user's calendar setting, don't silently switch: a user on a two-day trip may want to keep home time. Google Calendar asks whether to update the zone. Whichever zone wins, every view recomputes from instants, so nothing is stored in the wrong zone.

Flights are the classic travel event: an event can have a different zone for its start and its end (the Google API allows a separate timeZone on start and end), so "Depart 10:00 London, arrive 13:00 New York" is an 8-hour bar in a viewer's grid.

Check your understanding · Exercise 8

A New York user views the week of 1 November 2026, when clocks fall back. Which statements are correct? Select all that apply.

Drag to create, move, and resize

Direct manipulation is what makes a calendar feel fast: drag on empty space to create a meeting, drag an event to move it, drag its bottom edge to make it longer. Three gestures, one engine. The goals are a preview that follows the pointer at 60 frames per second, times that snap to 15 minutes, and a server write that's optimistic but safe.

Pointer events, capture, and a threshold

Use Pointer Events (pointerdown, pointermove, pointerup, pointercancel), which cover mouse, touch, and pen with one code path. On pointerdown, call setPointerCapture(e.pointerId) on the grid, so the moves keep arriving even when the pointer leaves the element or the window. Don't start a drag right away: wait until the pointer has moved about 4 pixels. Below that it's a click that opens the event's details.

On touch, a drag on the grid would fight with scrolling, so the time grid uses a long press (about 500 ms) to start a drag and touch-action: pan-y to keep vertical scrolling working until then.

ts
const SNAP = 15; // minutes
const PX_PER_MIN = 48 / 60;

function toMinutes(clientY: number, gridTop: number, scrollTop: number) {
  const raw = (clientY - gridTop + scrollTop) / PX_PER_MIN;
  return Math.max(0, Math.min(24 * 60, Math.round(raw / SNAP) * SNAP));
}

function onPointerMove(e: PointerEvent) {
  if (!drag) return;
  drag.lastX = e.clientX; drag.lastY = e.clientY;
  if (!frame) frame = requestAnimationFrame(applyPreview); // at most one update per frame
}

function applyPreview() {
  frame = 0;
  const day = dayIndexFromX(drag.lastX);
  const at = toMinutes(drag.lastY, grid.top, scroller.scrollTop) - drag.grabOffset;
  const next = drag.kind === "resize"
    ? { day: drag.day, start: drag.start, end: Math.max(drag.start + SNAP, at + drag.grabOffset) }
    : { day, start: at, end: at + drag.duration };
  if (!sameSlot(next, drag.preview)) { drag.preview = next; renderGhost(next); }
}
Pointer moves can arrive faster than the screen refreshes. Batching them into one requestAnimationFrame callback, and re-rendering only when the snapped slot changes, keeps each frame cheap.

Notice grabOffset. If the user grabs a one-hour event 20 minutes below its top, the event must keep that offset under the pointer, or it jumps on the first move. For moves across days, the column comes from the pointer's x position; in a week view of 7 columns that's Math.floor((x - gridLeft) / columnWidth), mirrored for right-to-left layouts.

Keep the drag cheap

The tempting implementation updates the event in the store on every move, which re-runs the overlap layout for the day and re-renders every event in the column. At 120 pointer events a second that's wasted work and visible jank. Instead, draw a ghost: one absolutely positioned element moved with transform: translateY(...), while the real events stay where they are.

The ghost only changes when the snapped slot changes, which is at most 4 times per hour of drag distance. The full layout runs once, on drop. The google-sheets guide treats its fill handle the same way: many pointer moves during the drag, one document edit at the end.

Auto-scroll near the edges

The grid shows about 10 of the 24 hours. Moving a meeting from 9:00 to 18:00 means dragging past the bottom edge. When the pointer is within about 40 pixels of the scroller's top or bottom edge, start a requestAnimationFrame loop that scrolls by a speed proportional to how close it is, and recompute the preview each frame because the content moved under a still pointer. Stop the loop on leaving the edge zone, on drop, and on pointercancel.

State machine of a drag. Idle goes to Pressed on pointerdown, which captures the pointer. Pressed goes back to Idle if the pointer is released before moving 4 pixels, which opens the event's details, or to Dragging once it moves 4 pixels. Dragging goes back to Idle on Escape or pointercancel. On drop, Dragging goes to Saving, or for a recurring event first to Choose scope, which goes to Saving once the user picks this, following, or all. Saving goes to Idle on 200 and to Rolled back on 412 or another error, which shows a toast and returns to Idle.IdlewaitingPressedcapturedDraggingghost, snap 15Rolled backtoast, RetrySavingoptimisticChoose scopeseries onlydownclick4 pxseriesdroppick200412 or errortoastEscape or pointercancel: Dragging back to Idle, nothing sent
Only Saving touches the network, and the store changes only on entering Saving, with the optimistic copy. Escape and pointercancel leave from Dragging with nothing to undo.

On drop: optimistic write, rollback on failure

On pointerup the client updates the store at once (the event moves, the layout runs), and sends PATCH /events/{id} with the new start and end and an If-Match header holding the event's etag. Three outcomes. Success: replace the optimistic copy with the server's (new etag).

A conflict, 412 Precondition Failed, because someone else changed the event since we loaded it: roll back to the server's version and tell the user "This event was changed by someone else", with the new time visible. Any other failure: roll back and show a toast with Retry. The optimistic updates lesson covers the store side of this pattern; the specific part here is that the rollback target is the last confirmed server copy, not the position before this drag, because a sync may have arrived in between.

Sequence diagram of moving an event by drag, between the pointer, the interaction layer, the event store, and the BFF. On pointerdown the interaction layer captures the pointer. Each move updates only a ghost, once per animation frame. On pointerup at Tuesday 10:15 the interaction layer commits to the store optimistically, so the event moves at once. The store sends PATCH with If-Match etag_8. On success the BFF replies 200 with etag_9. In the failure path, the BFF replies 412 with the current copy, because a colleague changed the event, and the store rolls back to that copy and shows a toast.PointerInteractionStoreBFFdown + capturemoves: ghost onlyup at Tue 10:15commit, optimisticIf-Match: etag_8200, etag_9or 412 + currentrollback + toast
The network sees one request per drag, not one per move. On 412 the rollback target is the server's current copy, which may differ from where the drag started.
  • Recurring events. Dropping an occurrence of a series opens the "this event, this and following, all events" choice before anything is sent. Until the user chooses, the ghost stays where it was dropped; Cancel puts it back.
  • Guests. If the event has guests, ask whether to send them an update. That choice travels with the write (Google's API has a sendUpdates parameter).
  • Permissions. Events the user can't edit (a colleague's event on a shared read-only calendar) don't start a drag at all; the cursor and an aria-disabled state say so.
  • Escape cancels the drag in progress and removes the ghost, so a misplaced drag never needs an undo.
  • Undo. After a successful move, a toast offers Undo for a few seconds, which sends the reverse change.

The keyboard alternative

Dragging must not be the only way. WCAG 2.2's criterion 2.5.7 (Dragging Movements) asks for a single-pointer alternative, and keyboard users need one too.

Three paths: the event details form with start and end fields always works; a focused event can be moved with keys (for example Alt plus arrow up or down moves it by 15 minutes, Alt plus left or right by a day), announced through a live region as "Moved to Tuesday 10:15 to 11:15"; and c opens the event form for the focused day, where start and end are typed. Each key move is a preview; Enter commits, Escape cancels, so a user doesn't send ten writes for ten key presses.

Check your understanding · Exercise 9

Which choices keep dragging smooth and safe? Select all that apply.

Many calendars, sharing, and finding a time

So far we've drawn one person's events. Real users look at several calendars at once: their own, a team calendar, a colleague's calendar shared with them, a holidays calendar, maybe a meeting room. Each one can be shared at a different level, and the level decides which fields the client is even allowed to receive. That makes permissions a data question, not just a button that's greyed out.

Permission levels and what each one sees

The Google Calendar API models sharing as an access control list (ACL) on each calendar, with the main roles freeBusyReader, reader, writer, and owner (plus none). Most calendar products have the same ladder. Each step up adds data the client receives, not just actions it may take:

LevelWhat the client receivesWhat the UI allows
Free/busy onlyBusy blocks: start and end, nothing else. No title, no guests, no id that leads to details.Grey "busy" boxes in the grid. No click to open, no drag.
See all detailsFull events, except those the owner marked private, which arrive as busy blocks.Open the details, copy the event. No drag, no edit.
Make changesFull events, including private ones if the product allows.Drag, resize, edit, delete.
OwnerEverything, plus the sharing settings themselves.All of the above, plus managing who has access.

The key rule: the server strips fields, the client never hides them. If a free/busy viewer's response contained the titles and the UI merely drew grey boxes, anyone with the developer tools open could read the CEO's meeting titles.

So the server returns a different shape for each level, and the client's types reflect that: a BusyBlock has a start and an end and nothing else, and the grid draws it with its own small component. This is also why the push nudge from the sync chapter carries no event data: the delta endpoint is the one place that applies these rules.

Event-level visibility adds one more layer. An owner can mark a single event private (visibility: "private" in Google's API), and then people with "see all details" get only a busy block for that event. The client needs no special logic: it draws whatever shape it receives.

What it must do is never cache a richer copy across users, for example on a shared kiosk. Cache keys include the user id, and sign-out clears IndexedDB.

Finding a time across attendees

When a user adds guests to a new meeting, the hardest question is "when are they all free?". The naive approach opens each guest's calendar and lets the user eyeball it. That fails at once: the user usually can't see guests' full calendars, and comparing six columns by eye across a week is slow and error-prone. The product answer is a free/busy query: ask the server for each guest's busy blocks over a range, without any details, and compute the gaps.

Decision Who computes the free slots for a group of guests?

Client merges busy blocks

Call a free/busy endpoint for all guests over the week (Google's is POST /freeBusy with timeMin, timeMax, and a list of calendar ids).

Merge every guest's busy intervals, invert them inside working hours, and draw the free gaps.

Pros

  • One cheap request, then instant feedback as the user drags the proposed time.
  • Busy blocks reveal nothing private.

Cons

  • The client doesn't know guests' working hours or zones unless the server sends them.
  • Suggestions are only as smart as the client code.

Server suggests times

Send the guests, the length, and the range; the server returns ranked suggestions.

Pros

  • Can use working hours, zones, room availability, and preferences the client can't see.

Cons

  • A round trip for each change of length or guest list.
  • A black box: the user can't see why a slot was picked.

Both

Draw each guest's busy blocks in a side-by-side grid (the client merge), and show a few server suggestions above it.

Pros

  • Transparent and smart. The grid explains the suggestions.

Cons

  • Two features to build and keep consistent.

What we pick: Start with the client merge: it's what the interview can go deep on, and it powers the side-by-side view that Google Calendar shows in its "Find a time" tab. Mention server suggestions as a later layer that can use data the client may not see, like working hours and rooms.

Merging is a sweep over sorted intervals. Put every guest's busy blocks in one list, sort by start, and merge any block that starts before the current merged block ends. What's left between merged blocks, clipped to working hours (say 9:00 to 17:00 in each guest's own zone), is free for everyone.

Keep only the gaps at least as long as the meeting. For 6 guests with 30 busy blocks each that's 180 intervals, sorted and merged in well under a millisecond, so the client can redo it on every change.

ts
type Span = { start: number; end: number }; // epoch ms

export function freeSlots(busy: Span[], window: Span, minLength: number): Span[] {
  const sorted = [...busy].sort((a, b) => a.start - b.start);
  const free: Span[] = [];
  let cursor = window.start;
  for (const b of sorted) {
    if (b.end <= cursor) continue;           // already covered
    if (b.start >= window.end) break;
    if (b.start - cursor >= minLength) free.push({ start: cursor, end: b.start });
    cursor = Math.max(cursor, b.end);
  }
  if (window.end - cursor >= minLength) free.push({ start: cursor, end: window.end });
  return free;
}
Instants in milliseconds are fine here: busy blocks are moments, not wall-clock times. Working hours are converted from each guest's zone to instants before the call.
Find a time for a one-hour meeting between 9:00 and 17:00. You are busy 9:00 to 10:00 and 13:00 to 14:00. Ana is busy 9:30 to 11:00 and 15:00 to 16:00. Ben is busy 12:00 to 13:00 and 14:00 to 14:30. A guest from another company shares nothing, so the row shows no information. Merging the busy blocks leaves free gaps at 11:00 to 12:00, 14:30 to 15:00, and 16:00 to 17:00. The 30-minute gap is too short; the two one-hour gaps fit.9:0011:0013:0015:0017:00YouAnaBenGuestno informationFree1 h1 hBusy blocks only, no titles. The 30-minute gap is too short.
Sort every busy block, merge, and keep the gaps at least as long as the meeting. Unknown is drawn as unknown: the guest's row is hatched, not empty.

Two practical limits. Free/busy for a large group or a mailing list is expensive, so Google's endpoint caps how many calendars and group members it expands in one call (the calendarExpansionMax and groupExpansionMax fields). And guests outside the organization often share nothing, so their row shows "no information" rather than "free". Drawing unknown as free is the bug to avoid: it invites meetings at 3 in the morning for the guest in Sydney.

Check your understanding · Exercise 10

A colleague shared their calendar with you as "free/busy only". Which statements are correct? Select all that apply.

Reminders and notifications

A reminder is the one feature that must work when the user isn't looking. "10 minutes before the 14:00 interview" has to reach the user whether the tab is open, in the background, closed, or the laptop just woke up. Each event can carry its own reminders, and each calendar has defaults. In Google's API an event has reminders: { useDefault, overrides }, where each override is a method (popup or email) and a number of minutes before the start.

The naive approach is a timer in the page: on load, for every event in the next day, setTimeout until its reminder time, then show a notification. It breaks in four ways. The tab may be closed, so nothing fires.

Browsers throttle timers in hidden tabs: Chrome runs them at most once a second, and once a tab has been hidden for five minutes it can batch chained timers to once a minute, so the reminder is late. A laptop asleep at the reminder time wakes up to a backlog of stale timers. And a user with the calendar open in three tabs and on a phone gets four copies.

Decision Where are reminders scheduled, and how do they reach the user?

Timers in the open tab

The page sets a timer per upcoming reminder and calls new Notification(...) when it fires.

Pros

  • No server work. Works without push permission while the tab is open.

Cons

  • Nothing fires when the tab is closed.
  • Background throttling makes it late.
  • Duplicates across tabs and devices.

Server schedules, Web Push delivers

The server computes the fire time of every reminder and keeps a queue.

At fire time it sends a Web Push message to each of the user's subscribed browsers; the service worker shows the notification.

Pros

  • Works with the tab closed, on every device, at the right time.
  • One schedule, recomputed when events change.

Cons

  • Needs notification permission and a push subscription per browser.
  • Push delivery is best effort and can be delayed by a few seconds or more.

Server schedules, open tab shows in-page alerts too

Same server schedule and Web Push.

An open, visible tab also gets a socket message and shows an in-page dialog; notifications share a tag per reminder so a repeat replaces instead of stacking.

Pros

  • The best experience when the tab is open, and a fallback when it isn't.
  • Works for users who denied notification permission but keep the tab open.

Cons

  • Two paths that must agree on which reminder is which.

What we pick: Compute reminders on the server and deliver with Web Push, plus an in-page alert when the tab is open and visible. The browser is only a display. This is the same split as the push notifications lesson: the server decides when, the push service carries it, the service worker shows it.

How the pieces work

  1. Permission at the right moment. Never ask on page load. Ask when the user turns on desktop notifications in settings, or right after they create their first event with a popup reminder, from a click (browsers ignore or penalize permission prompts without a user gesture).
  2. Subscribe. The service worker's registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }) returns an endpoint and keys. The client posts them to the server, tied to this user and this browser.
  3. Schedule on the server. For each event the server stores the next fire time per reminder: start instant minus the minutes. For a recurring series it schedules only the next occurrence, then the one after when that fires. Any change to the event, its series, or the user's defaults recomputes the entry.
  4. Deliver. At fire time the server sends a small push payload: event id, title, start, and a reminder id. The service worker's push handler calls showNotification with tag set to the reminder id, so a retried push never shows two copies in one browser. (With userVisibleOnly: true, every push must show a notification, so the worker can't silently skip one; the open tab's in-page alert simply closes the system notification when the user dismisses it.)
  5. Act. The notificationclick handler focuses an open calendar tab or opens a new one on the event's URL, and can offer a "Join" action that opens the meeting link.
Sequence diagram of a reminder between the tab, the server's scheduler, the push service, and the service worker. Once, the tab sends the push subscription with its endpoint and keys. The user saves an event at 14:00 with a popup reminder 10 minutes before. The scheduler queues a fire time of 13:50. At 13:50 it sends a push with the event id and a reminder tag to the push service, which delivers it to the service worker, even with the tab closed. The worker calls showNotification with the tag. When the user clicks, the worker focuses or opens the calendar tab on the event.TabSchedulerPush serviceWorkersubscriptionsave: 14:00, 10 minqueue: 13:5013:50 push + tagpush eventshowNotificationclick: open event
The browser only displays. The fire time lives on the server, so closed tabs, sleeping laptops, and four open devices all get one correct reminder; the tag stops repeats from stacking.

Because the fire time is computed from the event's instant, DST and travel need no special code: "10 minutes before" is 600 seconds before the start, wherever the user is. All-day events are the exception. A reminder for a birthday is "the day before at 9:00", which only makes sense in the viewer's zone, so the server needs the user's zone setting to compute it, and recomputes when that setting changes.

Check your understanding · Exercise 11

Which statements about reminders are correct? Select all that apply.

Architecture: the pieces and their boundaries

With the hard parts decided, we can name the pieces. The guiding idea is that time math, layout, and syncing are pure or headless modules with no React in them, and the view components only render what those modules produce. That keeps the hard logic testable with plain unit tests (feed in events and a zone, check the output) and keeps re-renders cheap.

Component architecture of the calendar web app. In the browser, the app shell owns the router and passes the URL's view and date to the views. The interaction layer, which handles drag and keys, draws a ghost in the views and on drop hands the change to the mutation queue. The layout engine turns instances into positions for the views. The event store, normalized with a copy in IndexedDB, gives instances to the layout engine. The sync engine merges fetched blocks and deltas into the store. The mutation queue applies optimistic changes to the store and sends PATCH requests. The time service with the zone and formatters is used by all of them. The service worker shows reminders and serves the offline shell. On the server, the BFF serves range reads, changes, writes, and WebSocket nudges, and a push service delivers reminders to the service worker.BrowserServerApp shellrouter, URLViewsday to yearInteractiondrag, keysTime serviceused by allLayoutpure, memoMutationsIf-Match, outboxWorkerpush, offlineSyncblocks, socketEvent storeby id, IDB copyBFFranges, changes, writes, nudgesPush serviceURLghostpositionsdropinstancesmergeapplyGET, WSPATCHpush
Each box has one job, and only the sync engine and the mutation queue talk to the server. Layout and time math are plain functions, so the hardest logic is the easiest to test.
PieceResponsibilityWhy it's its own piece
App shell and routerHeader, side panel, and the URL that holds view and date.Loads first and fast; everything else hangs off the URL.
ViewsDay, week, month, schedule, year. Each asks the store for a range and lays it out.They differ only in layout, so they share one data path.
Time serviceThe viewer's zone, week start, hour cycle, cached Intl.DateTimeFormat objects, and range math with Temporal.One place for zone rules, so no component does its own date math.
Layout engineOverlap columns for the grid, lanes for month and all-day bars, DST-aware positions.Pure functions, memoized per day; tested with fixed inputs.
Event storeNormalized instances by id, an index of which ids fall in which week block, block states, and calendar settings.Moves and syncs touch one record, and every view sees the change.
Sync engineFetches blocks, prefetches neighbors, holds sync cursors, listens to the socket, merges deltas.Freshness rules live in one place, away from the UI.
Mutation queueOptimistic writes with If-Match, rollback, retry, and an offline outbox.Ordering and conflict handling for every write, not per button.
Interaction layerPointer and keyboard drag, the ghost, auto-scroll, snapping.Runs per frame outside React state so drags stay smooth.
Service worker and IndexedDBOffline shell, cached events, push notifications for reminders.Works when the tab is closed or the network is gone.
Backend for frontendOne call for many calendars, the split endpoint, the delta endpoint, the socket.Shapes server data for this UI and applies permissions in one place.

Decision Where do the fetched event instances live on the client?

One query cache entry per range

A data-fetching library keyed by ['events', calendarId, week], each entry holding that block's array of instances.

Pros

  • Caching, deduping, and stale-while-revalidate come for free.
  • Least code.

Cons

  • An event that spans two weeks, or moves between them, lives in two arrays that can disagree.
  • An optimistic move must edit every array that holds the event.

Normalized store

Instances in one map by id; each week block stores only a list of ids and its state.

Pros

  • One copy per instance. A move updates one record and two id lists.
  • Deltas from sync apply by id without knowing which block held the event.

Cons

  • More code: we write the block bookkeeping ourselves.
  • Need to garbage collect instances no block refers to.

Query cache for fetching, normalized store for data

The query library handles requests, dedupe, and retries; its result is written into the normalized store, which the views read.

Pros

  • Both benefits: request handling from the library, one copy of each event.

Cons

  • Two layers to explain, and care to keep them from both holding data.

What we pick: Use a normalized store for the events and their block index, with a query library (or a small fetch layer) only for request mechanics. Events move between ranges all the time, by drag and by sync, and the normalization lesson's rule applies: one entity, one copy, many indexes.

Follow one action through the boxes to check the boundaries. The user presses j for next week. The router updates the URL to the next week. The week view asks the store for the 7 days, in the viewer's zone, computed by the time service.

The block is already cached because the sync engine prefetched it, so the store returns instance ids at once; the layout engine computes columns for each day, memoized by the day's list of instances; the view renders. In the background the sync engine starts prefetching the week after. No box did another box's job.

Code splitting follows the same lines. The shell, the week view, the store, and the layout engine are on the critical path. The event editor (with its rich text description, guest picker, and recurrence dialog), the find-a-time view, the year view, and settings are loaded on demand, and prefetched when the pointer rests on their trigger. A week view's first load stays around 150 to 200 KB of compressed JavaScript; the editor adds its weight only when someone opens it.

Check your understanding · Exercise 12

Which boundaries in this architecture are worth defending in an interview? Select all that apply.

Client data model and the API

The entities follow from the earlier chapters. The main split is between a series (the master with its rule) and an instance (one occurrence, which is what views draw). A single, non-repeating event is both: an instance with no series. Busy blocks from free/busy calendars are their own small type, so code can't accidentally read a title that isn't there.

ts
type When = { dateTime: string; timeZone: string } | { date: string }; // instant+zone or plain date

type Calendar = {
  id: string; name: string; color: string; textColor: string;
  access: "freeBusy" | "read" | "write" | "owner";
  selected: boolean;                          // the sidebar checkbox, synced per user
  defaultReminders: { method: "popup" | "email"; minutes: number }[];
};

type Instance = {
  id: string;                  // "ser_5_20260309T130000Z": series id + original start
  calendarId: string; etag: string; updated: string;
  seriesId?: string;           // set on occurrences and exceptions
  originalStart?: When;        // which occurrence this is (RECURRENCE-ID)
  start: When; end: When;
  title: string; location?: string; meetingUrl?: string;
  myResponse?: "accepted" | "declined" | "tentative" | "needsAction";
  canEdit: boolean; pending?: "saving" | "failed"; // optimistic state
};

type BusyBlock = { calendarId: string; start: string; end: string }; // no title, no id

type Block = {                 // one week of one calendar
  key: string;                 // "cal42:2026-W11"
  state: "loading" | "fresh" | "stale" | "error"; // no entry yet means Empty
  ids: string[]; fetchedAt: number;
};
Instances are what views read. Series details (the full rule, all guests) are fetched only when the editor opens.
DataWhere it livesWhy there
View and dateURLReload, back button, and shared links all work.
Calendars, colors, selected flagsStore, synced to user settings on the serverSame sidebar on every device.
Instances and block indexNormalized store in memory; recent weeks mirrored to IndexedDBInstant views, offline reads.
Sync cursors per calendarIndexedDB, next to the cached eventsA cursor is only valid for the data it was issued with.
Pending writesMutation queue, persisted to IndexedDBNot lost on reload; replayed when online.
Drag preview, open popovers, focusComponent or interaction-layer stateShort-lived and local to one gesture.
Zone, week start, hour cycleTime service, from user settings with the browser as fallbackEvery view and formatter reads one source.
The client's normalized store. Instances by id: evt_31 on Tuesday at 10:15, ser_5_0309 for the 9 March occurrence of a series on Monday at 9:00, and evt_77 on Wednesday at 14:00. Week blocks hold only ids and a state: W11 is fresh with 2 ids pointing to evt_31 and ser_5_0309, W12 is stale with 1 id pointing to evt_77, and W13 is loading. Calendars hold a color and a selected flag: cal42 blue and on, cal7 green and on, holidays grey and off. Busy blocks are a separate type with only start and end, and the outbox of pending writes is persisted in IndexedDB. Series details load only when the editor opens.instances by idweek blockscalendarsevt_31: Tue 10:15ser_5_0309: Mon 9:00evt_77: Wed 14:00W11 fresh: 2 idsW12 stale: 1 idW13 loadingcal42 blue, oncal7 green, onhol grey, offbusy blocks: own type,start and end onlyoutbox: pending writes,persisted in IndexedDBseries details load only when the editor opens
One copy of each instance, many indexes pointing at it. Moving an event to next week edits one record and two id lists, and every view that reads either block updates.

The API the client calls

Our BFF exposes a handful of endpoints shaped for this UI. They map closely to the Google Calendar API, with two additions: a multi-calendar range read, and a single split call for "this and following".

http
GET /api/events?calendars=cal42,cal7&from=2026-03-09T00:00:00-04:00&to=2026-03-16T00:00:00-04:00
200 { "calendars": { "cal42": { "events": [ ...Instance ], "busy": [] },
                     "cal7":  { "events": [], "busy": [ ...BusyBlock ] } },
      "cursors": { "cal42": "c_9f2", "cal7": "c_11a" } }

GET /api/changes?calendar=cal42&cursor=c_9f2
200 { "changed": [ ...Instance ], "deleted": ["evt_77"], "seriesChanged": ["ser_5"], "cursor": "c_a03" }
410 { "error": "cursor_expired" }          // wipe and refetch this calendar

PATCH /api/events/evt_31   If-Match: "etag_8"
{ "start": { "dateTime": "2026-03-10T10:15:00-04:00", "timeZone": "America/New_York" },
  "end":   { "dateTime": "2026-03-10T11:15:00-04:00", "timeZone": "America/New_York" },
  "notifyGuests": true }
200 { ...Instance, "etag": "etag_9" }
412 { "error": "conflict", "current": { ...Instance } }
Range reads take offsets, not bare dates, so the server never guesses the viewer's zone. A 412 returns the current copy, which saves a refetch for the rollback.
http
POST /api/series/ser_5/split
{ "at": { "originalStart": "2026-03-16T13:00:00Z" },
  "changes": { "start": { "dateTime": "2026-03-16T10:00:00-04:00", "timeZone": "America/New_York" } },
  "exceptionsAfter": "drop" }
200 { "old": { "id": "ser_5", "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20260316T125959Z"] },
      "new": { "id": "ser_9", "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=MO"] } }

POST /api/freebusy
{ "from": "2026-03-09T00:00:00Z", "to": "2026-03-14T00:00:00Z", "people": ["ana@x.com", "ben@x.com"] }
200 { "ana@x.com": { "busy": [ { "start": "...", "end": "..." } ], "workingHours": "09:00-17:00 America/New_York" },
      "ben@x.com": { "error": "no_access" } }

WS  server to client: { "type": "calendar.changed", "calendarId": "cal42" }
WS  server to client: { "type": "reminder", "eventId": "evt_31", "reminderId": "r_31_10" }
The split happens in one transaction on the server. Socket messages are nudges with ids only; data comes from the changes endpoint.

Errors share one shape, { error, message, current? }, so the mutation queue can handle them in one place: 412 means conflict and roll back to current; 403 means the permission changed and the calendar should be refetched; 429 and 5xx mean retry with backoff; a network failure while offline means keep the write in the outbox. Each write also carries a client-generated requestId so a retried create after a timeout doesn't make two events.

Check your understanding · Exercise 13

Which data-model choices hold up? Select all that apply.

Performance, the current-time line, and offline

A calendar is opened dozens of times a day, often just to glance at the next meeting. So the targets are about the first glance and about every interaction after it. Using the Core Web Vitals "good" thresholds: Largest Contentful Paint (LCP) under 2.5 seconds, Interaction to Next Paint (INP) under 200 ms, and Cumulative Layout Shift (CLS) under 0.1. For a calendar, LCP is usually the week grid, INP is week navigation and drop, and CLS is the all-day strip growing after data loads.

The first glance

  • Draw from the local copy first. On start, read the visible weeks from IndexedDB and render them, marked stale, then revalidate. A returning user sees their week in a few hundred milliseconds, before any network call returns.
  • Reserve the all-day strip. Its height depends on how many all-day lanes the week has, which is only known after data loads. Give it the height from the cached copy, or one lane by default, so the grid below doesn't jump.
  • Scroll to the right hour before paint. The grid shows about 10 of 24 hours. Set scrollTop to put the current time about a third from the top (or the first event of the day) in a layout effect, before the first paint, so the user never sees a jump from midnight.
  • Keep the critical bundle small. The code split from the architecture chapter keeps the editor, find-a-time, and the year view off the first load.

Rendering and layout cost

A week view holds about 50 events, so it's a few hundred DOM nodes: no virtualization needed. What matters is not doing work twice. Memoize the layout of each day by a key built from its instances' ids and etags, so a sync that changes Tuesday reruns Tuesday's layout and nothing else, and wrap each event block in React.memo keyed by id.

During a drag nothing in the store changes, so nothing relayouts; only the ghost moves, with transform, which the browser can do on the compositor without layout. The hour lines and half-hour lines are one repeating background on the column, not 48 elements per day.

The schedule view is different: a list that can run for months, with day headers and rows of different heights. That's the case for virtualization, covered in the virtualization lesson: render only the rows near the viewport, measure real heights as rows render, and keep scroll position stable when earlier weeks are loaded above (scroll anchoring, or adjusting scrollTop by the inserted height). Loading is driven by an IntersectionObserver on sentinels near both ends, fetching the next week block in each direction.

The current-time line

The red line across today's column looks trivial and hides three bugs. First, setInterval(update, 60000) drifts and fires mid-minute, so the line lags the clock by up to a minute; instead, set a timeout for the time left until the next whole minute, update, and repeat. Second, background tabs throttle timers, so when the tab becomes visible again (visibilitychange), update immediately.

Third, the position is the wall-clock minutes since local midnight, the same rule as the grid rows, or on a DST day the line is an hour off from the labels. When the date rolls over at midnight, the "today" column highlight moves too, and in the week view that may mean the line leaves the visible week.

ts
function startNowLine(zone: string, onTick: (minutes: number, date: string) => void) {
  let timer = 0;
  const tick = () => {
    const now = Temporal.Now.zonedDateTimeISO(zone);
    onTick(now.hour * 60 + now.minute, now.toPlainDate().toString()); // wall-clock minutes
    timer = window.setTimeout(tick, 60_000 - (now.second * 1000 + now.millisecond));
  };
  const onVisible = () => { if (!document.hidden) { clearTimeout(timer); tick(); } };
  document.addEventListener("visibilitychange", onVisible);
  tick();
  return () => { clearTimeout(timer); document.removeEventListener("visibilitychange", onVisible); };
}
The line's top is minutes times pixels per minute, applied with a transform. Only the line re-renders each minute, not the grid.

Offline

Decision How much should work without a network?

Online only

No local copy. Offline shows an error page.

Pros

  • Nothing to build. No stale data or conflicts.

Cons

  • On a plane or a train the user can't even see where their next meeting is.

Read-only offline

A service worker caches the app shell.

Recent and upcoming weeks are mirrored to IndexedDB and shown with an "offline" banner; editing controls are disabled.

Pros

  • Answers the common offline question, "where am I supposed to be?"
  • No conflict handling needed.

Cons

  • Users can't accept an invite or move a meeting until they're back online.

Offline edits with an outbox

Edits apply locally and wait in a persisted outbox.

On reconnect they replay in order with If-Match; conflicts are shown to the user.

Pros

  • Full use on a flight.

Cons

  • Conflicts are likely for shared events, and guests get updates hours late.
  • Recurring edits offline need the client expansion engine to be exact.

What we pick: Ship read-only offline first, and allow a small set of safe offline edits later (replying to an invite, editing your own single events) through the outbox. That's also roughly where Google Calendar is at the time of writing: its help pages describe an offline setting in Chrome that shows recent and upcoming events, without creating or editing while offline. The offline-first lesson and the Gmail guide cover the shell caching and the outbox in depth.

How much to keep? Four weeks back and all of the next few months for the user's own calendars is a few thousand instances, about 2 to 5 MB in IndexedDB, well within browser storage quotas. Shared team calendars can be much bigger, so limit them to a few weeks around today. The sync cursors are stored with the data, so coming back online is a delta sync per calendar, not a full refetch.

Check your understanding · Exercise 14

Which of these improve the calendar's real performance? Select all that apply.

Accessibility and internationalization

A week view is a two-dimensional picture: position means time, width means overlap, color means calendar. None of that reaches a screen reader user, and a keyboard user can't point at 10:30 on Thursday. So accessibility here is a design problem, not a matter of adding labels at the end. The question is what structure to expose.

Decision What structure should assistive technology see in the week view?

A full grid of time slots

role="grid" with 7 columns and 96 rows of 15-minute cells; events live inside the cells they start in.

Pros

  • Arrow keys can reach any empty slot to create an event there.
  • Matches the visual layout exactly.

Cons

  • 672 cells, most empty. Moving from Monday 9:00 to Monday 17:00 is 32 key presses.
  • Overlapping events don't fit the one-cell-one-thing model, and long events span many cells.

Day groups with event buttons

Each day column is a labelled group ("Tuesday, 10 March, 4 events").

Events are buttons in time order with a full accessible name; arrow keys move between them with a roving tabindex.

Pros

  • Every stop is something real. A busy week is about 50 stops, not 672.
  • Overlap doesn't matter: the order is by time.

Cons

  • Empty time isn't reachable by arrows, so creating at a time needs another path.

A list view as the alternative

The schedule view is a plain list of days and events, fully readable in order.

Pros

  • The simplest structure for a screen reader. Works for every user who prefers lists.

Cons

  • It's a different view: someone who wants the week's shape still needs the grid to make sense.

What we pick: Use day groups with event buttons in the grid, and keep the schedule view as a first-class list alternative one key away. Creating at a time goes through c, which opens the event form for the focused day, with typed start and end times. That combination gives every user the same powers without 672 tab stops.

Left, the week grid as it looks: Monday is empty, Tuesday has Review in the morning and a short 1:1 in the afternoon, and Wednesday has Launch in the morning. Right, the structure assistive technology gets: a group for Tuesday 10 March with 2 events, holding buttons for Design review from 10:00 to 11:00 and 1:1 with Ana from 14:00 to 14:30, then a group for Wednesday 11 March with 1 event, holding a button for Launch from 9:00 to 10:00. Empty Monday adds no stops. Arrow keys move between events, c creates an event, and a opens the list view.on screenwhat a screen reader getsMoTuWeempty:no stopsReview1:1Launchgroup: Tue 10 March, 2 eventsDesign review, 10:00 to 11:001:1 with Ana, 14:00 to 14:30group: Wed 11 March, 1 eventLaunch, 9:00 to 10:00arrows: next event, c: create, a: list view
Every stop is something real, in time order, with a name that says what the picture shows. Empty time is reached through the create shortcut and typed times, not hundreds of empty cells.

What each event says

An event button's accessible name carries everything the picture shows: "Design review, Tuesday 10 March, 10:00 to 11:00, Team calendar, you accepted, overlaps with 1 other event". Build it with the same Intl.DateTimeFormat objects the labels use, so the spoken time matches the screen in every locale.

Color is never the only signal: the calendar name is in the label, a declined event gets "declined" in its name and a line-through on screen, and each calendar has a textColor chosen for contrast against its background (at least 4.5 to 1 for small text under WCAG). When the user changes the range, a polite live region says "Week of 9 March 2026"; after a keyboard move, it says the new time.

Keyboard shortcuts

Google Calendar has single-key shortcuts, turned on in its settings, that are worth copying because power users expect them. According to its help page, d, w, m, x, and a switch to the day, week, month, custom, and agenda (schedule) views, as do 1 to 5. j or n go to the next range and k or p to the previous, t jumps to today, and g goes to a date.

For events, c creates one, e opens the focused event's details, Delete or Backspace deletes it, and z undoes. / puts the cursor in search, and ? lists every shortcut.

  • Let users turn them off. WCAG 2.1.4 (Character Key Shortcuts) asks that single-character shortcuts can be turned off or remapped, because speech input users trigger them by accident. A setting does it.
  • Ignore them while typing. The handler skips events whose target is an input, a textarea, or contenteditable, and any event with Ctrl, Alt, or Meta held.
  • Mirror them in the UI. Every shortcut has a visible button, and the button's tooltip shows the key, so shortcuts are a speedup, never the only way.
  • Manage focus on every change. After creating, focus the new event. After deleting, focus the next event in time. Popovers and the editor are dialogs that trap focus and return it to the trigger on close. The ARIA patterns and keyboard navigation lessons cover roving tabindex and dialogs in detail.
  • Respect reduced motion. With prefers-reduced-motion: reduce, switch weeks without the slide animation and drop the drag ghost's easing.

Internationalization

ConcernExampleHow the client handles it
First day of the weekSunday in the US, Monday in most of Europe, Saturday in parts of the Middle EastA user setting, defaulting to the locale: new Intl.Locale(lang).getWeekInfo() in browsers that support it, else CLDR data shipped with the app. It changes every week block key, so the cache must key on it.
12 or 24 hour clock"2 PM" versus "14:00"A user setting, defaulting to the locale's hourCycle from Intl.DateTimeFormat. Grid labels, event times, and accessible names all use the same formatter.
Date formats and names"Tue 10/3" versus "mar. 10 mars"Always Intl.DateTimeFormat with options, never hand-built strings. Pick weekday: "short" or "narrow" by column width, since some languages' short names are long.
Right to leftArabic and Hebrew interfacesThe day columns run right to left, so the drag code's x-to-day math and the arrow keys must mirror too. Use logical CSS properties like inset-inline-start.
Other calendar systemsHebrew, Islamic, Persian, or Chinese datesThe grid stays Gregorian; extra labels come from Intl.DateTimeFormat with a calendar option such as hebrew or islamic-umalqura. Google Calendar offers this as an alternate calendar setting.
Holidays and weekendsFriday and Saturday weekends in some countriesWeekend shading comes from locale week info, not a hard-coded Saturday and Sunday. Holiday calendars are just calendars.

Most of this is about one rule from the locale formatting and i18n lessons: format at the edge with Intl, keep data in instants and plain dates, and never concatenate date strings. Cache the formatter objects in the time service, because creating an Intl.DateTimeFormat costs far more than calling format on one, and a week view formats hundreds of labels.

Check your understanding · Exercise 15

Which choices make the calendar accessible and international? Select all that apply.

Running it in the interview

This guide is far more than fits in 60 minutes, so the skill is choosing. Interviewers for a calendar question usually probe two or three of: the time model, recurrence, overlap layout, dragging, and sync. Show you know all five exist in the first ten minutes, then go deep where they lean.

MinutesWhat to doWhat to draw
0 to 7Requirements: views, recurrence, zones, sharing, reminders, scale. State that you'll design the week view in depth.A short list of functional and non-functional requirements.
7 to 12Name the hard parts: time is not a number line, recurrence with exceptions, overlap, ranged and shared data, direct manipulation.The week view wireframe with the all-day strip and the grid.
12 to 22The event model: instant plus zone, plain dates for all-day, the event's zone versus the viewer's. Recurrence: masters, exceptions, server expansion, the three edit choices.An event object, and a series with one moved and one cancelled occurrence.
22 to 32Architecture and data flow: store, sync engine, layout engine, mutation queue, BFF. Week blocks, prefetch, push nudge plus delta sync.The component diagram and the fetch and sync arrows.
32 to 45Deep dive where the interviewer leans: usually overlap layout (cluster, columns, expand) or drag (pointer capture, ghost, snapping, optimistic write with If-Match).Four overlapping events and their columns, or the drag sequence with the failure path.
45 to 55DST days, reminders on the server, free/busy, accessibility and shortcuts, offline reading.The 25-hour day, the reminder push path.
55 to 60Summarize the decisions and what you'd build next.Nothing new; point at the diagram.

Some phrases show seniority when said at the right moment. Each one packs a decision into a sentence the interviewer can check:

  • "Timed events are instants with a zone, all-day events are plain dates, and I'll never mix the two."
  • "Recurrence expands in the event's zone, then converts to the viewer's."
  • "Touching events don't overlap."
  • "The drag preview is a ghost; the store changes once, on drop."
  • "The push message is a nudge, the delta endpoint is the truth."
  • "Permissions are applied on the server; a free/busy viewer never receives a title."

Follow-up questions to expect

Think first

A user drags one occurrence of a weekly meeting from Monday to Tuesday and picks "this event". Walk through what happens.

Think first

A user overlays a meeting room calendar with 3,000 events a month on their week view. What changes?

Think first

The user has the calendar open in two tabs and moves an event in one. How does the other tab update?

  • Common mistake: storing everything as UTC instants. It breaks all-day events and recurring meetings across DST. Say "instant plus zone" early.
  • Common mistake: dividing the whole day by the maximum overlap. Overlap is per cluster; a lone event keeps its full width.
  • Common mistake: updating state on every pointer move. It costs the 16 ms frame budget. Ghost during, commit on drop.
  • Common mistake: client-side timers for reminders. Closed tabs don't fire. The server schedules, Web Push delivers.
  • Common mistake: hiding fields in the UI for limited sharing. If the response has the title, it's leaked.

Check your understanding · Exercise 16

The interviewer says: "We have 15 minutes left. Pick one area to go deep on." Which choices make strong use of that time? Select all that apply.

Summary

  • Timed events are an instant plus the event's own time zone; all-day events are plain dates with an exclusive end. Display always uses the viewer's zone.
  • Recurrence is a master with an RRULE, EXDATEs, and exceptions keyed by original start. Expand in the event's zone, render server-expanded instances, and keep a small client expander for previews and optimistic edits.
  • "This and following" is a split: end the old series and start a new one, in one server transaction.
  • Fetch by week blocks in the viewer's zone, keep events normalized with a block index, prefetch neighbors, and stay fresh with a push nudge plus a delta sync on nudge, focus, and reconnect.
  • Overlap layout: sort, cluster by chained overlap, assign columns greedily, then expand each event right while nothing collides. Touching events don't overlap.
  • Use wall-clock rows on DST days, take heights from real durations when the wall clock lies, and never add 24 hours to get the next day.
  • Drag with Pointer Events and capture, move a ghost per animation frame, snap to 15 minutes, commit once with If-Match, and roll back to the last confirmed server copy.
  • Reminders are scheduled on the server and delivered by Web Push; permissions are enforced on the server, so a free/busy viewer never receives a title.
  • Accessibility needs a structure, not just labels: day groups with event buttons, a list view alternative, and shortcuts that can be turned off.

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 4

References

Concepts behind it

Try next