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.
| Question | Answer we assume | What 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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.
| View | Range it shows | What it needs per event | Layout problem |
|---|---|---|---|
| Day | One day, 00:00 to 24:00 in the viewer's zone | Start, end, title, color, location, guests' reply status | Overlap columns inside one day column. Lots of room, so more text per event. |
| Week | 7 days (or 5 with weekends hidden), from the locale's first day of the week | Start, end, title, color | Overlap columns in 7 day columns, plus multi-day bars in the all-day strip. |
| Month | The 4 to 6 weeks that cover the month, so 28 to 42 days including the edges of the previous and next months | Start date, title, color, and whether it's all-day | Bars that span days and weeks, a row height limit, and "+3 more" overflow. |
| Schedule | From today forward, loaded as you scroll; can reach months | Start, end, title, calendar, location | A long list grouped by day. Needs virtualization. |
| Year | 12 months | Only which days have events, or nothing at all | 12 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".
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?
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.
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:
| Term | What it means | Example |
|---|---|---|
DTSTART | The first start, with its zone | DTSTART;TZID=America/New_York:20260105T090000 |
RRULE | The repeat rule | RRULE:FREQ=WEEKLY;BYDAY=MO |
EXDATE | Starts to skip (a cancelled occurrence) | EXDATE;TZID=America/New_York:20260216T090000 |
RDATE | Extra starts added to the rule | One extra session on a Saturday |
RECURRENCE-ID | On an exception: which original occurrence it replaces | The 2 March standup moved to 10:00 |
UID | The series' identity across systems | Shared by the master and its exceptions |
SEQUENCE | A revision number for invites | Raised 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?
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.
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;
}
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.
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:
| Choice | What the client sends | What happens to existing exceptions |
|---|---|---|
| This event | Create 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 events | Split 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 events | Update 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.
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?
It should keep 8 occurrences. You can write that as COUNT=8, or replace COUNT with an UNTIL just before the 9th occurrence's original start. RFC 5545 says a rule has either COUNT or UNTIL, never both.
No new master is created, because nothing continues. Any exceptions from the 9th onward should be deleted too, otherwise moved occurrences would linger as orphans.
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.
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.
Incremental sync: ask only for what changed
Decision How does an open tab learn that a colleague moved a meeting?
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.
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?
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
- 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.
- 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.
- 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.
- Width. Each event's base width is one column:
100% / columnsof its cluster, and its left edge iscolumn * width. - 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.
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
}
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.
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.
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:
- 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.
- 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.
- 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.
- 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.
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?
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'sstartOfDay()handles that.
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.
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); }
}
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.
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.
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
sendUpdatesparameter). - 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-disabledstate 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.
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?
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
- 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).
- 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. - 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.
- Deliver. At fire time the server sends a small push payload: event id, title, start, and a reminder id. The service worker's
pushhandler callsshowNotificationwithtagset to the reminder id, so a retried push never shows two copies in one browser. (WithuserVisibleOnly: 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.) - Act. The
notificationclickhandler 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.
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.
| Piece | Responsibility | Why it's its own piece |
|---|---|---|
| App shell and router | Header, side panel, and the URL that holds view and date. | Loads first and fast; everything else hangs off the URL. |
| Views | Day, 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 service | The 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 engine | Overlap columns for the grid, lanes for month and all-day bars, DST-aware positions. | Pure functions, memoized per day; tested with fixed inputs. |
| Event store | Normalized 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 engine | Fetches blocks, prefetches neighbors, holds sync cursors, listens to the socket, merges deltas. | Freshness rules live in one place, away from the UI. |
| Mutation queue | Optimistic writes with If-Match, rollback, retry, and an offline outbox. | Ordering and conflict handling for every write, not per button. |
| Interaction layer | Pointer and keyboard drag, the ghost, auto-scroll, snapping. | Runs per frame outside React state so drags stay smooth. |
| Service worker and IndexedDB | Offline shell, cached events, push notifications for reminders. | Works when the tab is closed or the network is gone. |
| Backend for frontend | One 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?
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.
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;
};
| Data | Where it lives | Why there |
|---|---|---|
| View and date | URL | Reload, back button, and shared links all work. |
| Calendars, colors, selected flags | Store, synced to user settings on the server | Same sidebar on every device. |
| Instances and block index | Normalized store in memory; recent weeks mirrored to IndexedDB | Instant views, offline reads. |
| Sync cursors per calendar | IndexedDB, next to the cached events | A cursor is only valid for the data it was issued with. |
| Pending writes | Mutation queue, persisted to IndexedDB | Not lost on reload; replayed when online. |
| Drag preview, open popovers, focus | Component or interaction-layer state | Short-lived and local to one gesture. |
| Zone, week start, hour cycle | Time service, from user settings with the browser as fallback | Every view and formatter reads one source. |
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".
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 } }
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" }
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
scrollTopto 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.
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); };
}
Offline
Decision How much should work without a network?
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?
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.
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, atextarea, orcontenteditable, 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
tabindexand 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
| Concern | Example | How the client handles it |
|---|---|---|
| First day of the week | Sunday in the US, Monday in most of Europe, Saturday in parts of the Middle East | A 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 left | Arabic and Hebrew interfaces | The 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 systems | Hebrew, Islamic, Persian, or Chinese dates | The 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 weekends | Friday and Saturday weekends in some countries | Weekend 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.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 7 | Requirements: 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 12 | Name 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 22 | The 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 32 | Architecture 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 45 | Deep 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 55 | DST days, reminders on the server, free/busy, accessibility and shortcuts, offline reading. | The 25-hour day, the reminder push path. |
| 55 to 60 | Summarize 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.
The ghost stays at Tuesday while the choice dialog is open. On "this event", the client optimistically updates that instance's start and end and marks it pending.
It sends a write that creates or updates an exception: the series id, the original start (Monday's 9:00, unchanged), and the new start and end. The server returns the exception with its own etag; the client swaps it in.
Monday's slot stays empty because the expansion matches the exception by original start. If the write fails, the instance returns to Monday and a toast offers Retry.
Think first
A user overlays a meeting room calendar with 3,000 events a month on their week view. What changes?
Fetching stays by week block, so it's about 700 events for the visible week, not 3,000. The layout still runs per day, now with maybe 100 events in a day, still a millisecond or two.
The real problem is readability: columns become too narrow. Show the room as busy blocks with no titles, or offer a side-by-side "day view by calendar" where each calendar gets its own column, as some calendar apps offer for rooms and teams.
Sync for a busy shared calendar sends many nudges, so the client should debounce them, for example one delta sync per calendar per few seconds.
Think first
The user has the calendar open in two tabs and moves an event in one. How does the other tab update?
Through the normal path: the server sends a nudge on each tab's socket, and each runs a delta sync. The second tab sees the move within a second or two.
For instant updates, the writing tab can also post the change on a BroadcastChannel; other tabs apply it optimistically and still confirm with their delta sync. To save connections, one leader tab can hold the socket and share nudges with the others over the same channel.
- 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.
References
- RFC 5545: Internet Calendaring and Scheduling Core Object Specification (iCalendar) RRULE, EXDATE, RECURRENCE-ID, and the all-day date rules.
- Google Calendar API: Events resource start and end with dateTime or date, recurrence, recurringEventId, originalStartTime, reminders.
- Google Calendar API: Synchronize resources efficiently syncToken, nextSyncToken, and the 410 Gone full resync.
- Google Calendar API: Push notifications events.watch and web_hook channels.
- Google Calendar API: Freebusy query
- Google Calendar Help: Keyboard shortcuts
- MDN: Temporal
- MDN: Pointer events
- MDN: Push API
- WCAG 2.2: Understanding Dragging Movements (2.5.7)
Concepts behind it
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Web push notificationsMessages your server sends through the browser's push service, which wake your service worker to show a notification even when your site isn't open: powerful for re-engagement, and easy to ruin with a bad permission prompt.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.