- Problem Statement & Scope
- Functional Requirements
- Non-Functional Requirements
- Data Modelling
- High-Level Architecture
- 5.1 Component Tree
- 5.2 State Management Architecture
- 5.3 Data Flow
- UI Views & Rendering
- 6.1 Month View
- 6.2 Week View
- 6.3 Day View
- 6.4 Agenda View
- Recurrence Engine
- Drag & Drop System
- Timezone Handling
- Performance Optimisations
- 10.1 Virtual Rendering
- 10.2 Event Overlap Layout Algorithm
- 10.3 Caching & Prefetching
- Real-Time Sync & Conflict Resolution
- Offline Support & Service Workers
- Accessibility (a11y)
- API Contract
- Testing Strategy
- Interview Cheat Sheet
Design the frontend of a Google Calendar-like application that supports:
- Creating, editing, and deleting events
- Multiple calendar views (month, week, day, agenda)
- Recurring events with full RFC 5545 iCalendar RRULE support
- Multi-timezone display
- Drag-and-drop scheduling
- Real-time collaboration (shared calendars)
- Offline-first capability
Out of scope for this discussion: Backend service design, authentication infrastructure, billing systems.
Scale assumptions:
- A power user has ~500 events/month across 5 calendars
- View renders in < 100ms after data is cached
- Real-time updates propagate in < 2 seconds
- CRUD events — title, start/end datetime, location, description, guests, colour
- Recurring events — daily, weekly, monthly, yearly, with exceptions
- Calendar views — month, week, 3-day, day, schedule/agenda
- Multi-calendar — personal, shared, subscribed (read-only), birthdays
- Invitations — accept / decline / tentative with RSVP tracking
- Notifications — browser push, in-app badges, email digest
- Timezone — per-user setting; display events in local time; all-day events span midnight correctly
- Drag & drop — move event across time slots; resize event duration
- Quick-add — natural language input ("Lunch with Tom Friday 1pm")
- Event search — full-text across title, description, guests
- Conflict detection — warn when two events overlap
- Goals & tasks — separate entity type with auto-scheduling AI
- Google Meet integration — auto-generate video link
- Dark mode
- Keyboard shortcuts (n = new event, t = today, 1/2/3 = day/week/month)
- Print view
- ICS import/export
| Quality Attribute | Target | How Achieved |
|---|---|---|
| Initial Load (TTI) | < 3s on 4G | Code splitting per view, skeleton screens |
| View Switch | < 100ms | Pre-rendered adjacent views, virtual scroll |
| Event Render (week, 200 events) | < 16ms (60fps) | Canvas fallback, CSS containment |
| Availability | 99.9% | Service Worker + IndexedDB offline cache |
| Real-time latency | < 2s | WebSocket / Server-Sent Events (SSE) |
| Accessibility | WCAG 2.1 AA | ARIA grid roles, keyboard nav, focus traps |
| Bundle size | < 150KB gzipped (initial) | Tree shaking, lazy routes |
| Memory | < 100MB for 12-month view | Event virtualisation, WeakMap caches |
The canonical event object lives in the frontend store. It mirrors the Google Calendar API v3 Event resource with some client-side additions.
interface CalendarEvent {
// Identity
id: string; // UUID, local-first generated
calendarId: string;
etag: string; // Optimistic concurrency token
// What
title: string;
description?: string;
location?: string;
colorId?: CalendarColor; // Enum: 'tomato' | 'flamingo' | 'tangerine' | ...
attachments?: Attachment[];
conferenceData?: ConferenceData; // Google Meet link
// When
start: EventDateTime; // { dateTime: ISO8601, timeZone } OR { date: 'YYYY-MM-DD' }
end: EventDateTime;
allDay: boolean;
// Recurrence
recurrence?: string[]; // RRULE, EXRULE, RDATE, EXDATE strings
recurringEventId?: string; // Points to the master event
originalStartTime?: EventDateTime; // For exceptions/instances
isException: boolean; // This instance deviates from RRULE
// Who
organizer: Person;
creator: Person;
attendees?: Attendee[];
guestsCanModify: boolean;
guestsCanInviteOthers: boolean;
// State
status: 'confirmed' | 'tentative' | 'cancelled';
visibility: 'default' | 'public' | 'private' | 'confidential';
transparency: 'opaque' | 'transparent'; // Blocks time or not
// Notifications
reminders: {
useDefault: boolean;
overrides?: Reminder[];
};
// System
created: ISO8601;
updated: ISO8601;
sequence: number; // iCal SEQUENCE for change tracking
syncToken?: string;
// Client-only (not persisted to server)
_localStatus?: 'syncing' | 'error' | 'dirty';
_computedInstances?: EventInstance[]; // Expanded recurrence instances
}
interface EventDateTime {
date?: string; // 'YYYY-MM-DD' for all-day events
dateTime?: string; // ISO8601 with timezone for timed events
timeZone?: string; // IANA tz identifier e.g. 'America/New_York'
}
interface Attendee {
email: string;
displayName?: string;
self?: boolean;
responseStatus: 'needsAction' | 'declined' | 'tentative' | 'accepted';
organizer?: boolean;
resource?: boolean; // Conference room
}
interface Reminder {
method: 'email' | 'popup';
minutes: number;
}Recurrence is stored as RFC 5545 strings (the same format as iCal). The frontend ships a lightweight RRULE expansion engine.
// Parsed RRULE object (internal to the recurrence engine)
interface ParsedRRule {
freq: 'SECONDLY' | 'MINUTELY' | 'HOURLY' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
interval?: number; // Default 1. FREQ=WEEKLY;INTERVAL=2 = biweekly
until?: Date; // Recur until this datetime (exclusive)
count?: number; // Recur exactly N times (mutually exclusive with until)
byDay?: ByDay[]; // e.g. ['MO', 'WE', 'FR'] or ['-1SA'] (last Saturday)
byMonthDay?: number[]; // e.g. [1, 15] = 1st and 15th of month
byMonth?: number[]; // e.g. [1, 7] = January and July
bySetPos?: number[]; // e.g. [-1] = last occurrence in set
weekStart?: DayOfWeek; // Default MO
exDates?: Date[]; // Dates excluded from rule (EXDATE)
rDates?: Date[]; // Extra dates added outside rule (RDATE)
}
type ByDay = DayOfWeek | `${'-' | ''}${number}${DayOfWeek}`;
type DayOfWeek = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';Common RRULE examples:
# Every weekday
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
# Every 2 weeks on Monday
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO
# Last Friday of every month
RRULE:FREQ=MONTHLY;BYDAY=-1FR
# First Monday of every month
RRULE:FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1
# Every year on March 15, ending after 5 occurrences
RRULE:FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=15;COUNT=5
interface UserSettings {
userId: string;
displayName: string;
email: string;
photoUrl?: string;
locale: string; // 'en-US'
timezone: string; // 'Asia/Kolkata'
weekStart: 0 | 1 | 6; // 0=Sun, 1=Mon, 6=Sat
use24HourTime: boolean;
defaultView: ViewType;
defaultReminderMinutes: number;
workingHours: { start: string; end: string; days: number[] };
}
interface Calendar {
id: string;
summary: string;
description?: string;
timeZone: string;
colorId: CalendarColor;
backgroundColor: string; // Hex
foregroundColor: string; // Hex for text on background
accessRole: 'freeBusyReader' | 'reader' | 'writer' | 'owner';
primary?: boolean; // User's main calendar
selected: boolean; // Shown in UI
kind: 'user' | 'shared' | 'resource' | 'birthday' | 'holiday';
}interface Notification {
id: string;
eventId: string;
calendarId: string;
eventTitle: string;
eventStart: string;
triggerTime: string; // When to fire the notification
method: 'popup' | 'email';
dismissed: boolean;
snoozedUntil?: string;
}App
├── RouterProvider
│ ├── AuthGuard
│ └── CalendarShell
│ ├── TopBar
│ │ ├── Logo
│ │ ├── DateNavigator ← prev / today / next
│ │ ├── ViewSwitcher ← Day | Week | Month | Agenda
│ │ └── SearchBar
│ ├── SidePanel
│ │ ├── MiniCalendar ← month thumbnail, clickable
│ │ ├── CalendarList ← toggleable calendar checkboxes
│ │ └── CreateEventButton
│ └── MainView ← code-split per view
│ ├── MonthView
│ │ └── MonthGrid → WeekRow[] → DayCell[] → EventChip[]
│ ├── WeekView
│ │ ├── AllDayBanner ← multi-day / all-day events
│ │ └── TimeGrid ← hourly rows × 7 columns
│ │ └── TimeSlot[] → EventBlock[]
│ ├── DayView ← same as WeekView, single column
│ └── AgendaView
│ └── AgendaGroup[] → AgendaItem[]
├── EventModal ← shared overlay for create/edit
│ ├── EventForm
│ ├── RecurrenceEditor
│ ├── GuestPicker
│ ├── ReminderEditor
│ └── ConferenceToggle
└── NotificationCenter
Use a layered state approach:
┌─────────────────────────────────────────────────────────┐
│ Server State (React Query / SWR) │
│ - events per calendar per date range │
│ - user settings │
│ - calendar list │
│ - Stale-while-revalidate + background sync │
└───────────────────────┬─────────────────────────────────┘
│ normalized + merged into
┌───────────────────────▼─────────────────────────────────┐
│ Global UI State (Zustand / Redux Toolkit) │
│ - currentView: ViewType │
│ - selectedDate: Date │
│ - visibleDateRange: { start, end } │
│ - activeCalendars: Set<calendarId> │
│ - draggingEventId: string | null │
│ - openModal: ModalState | null │
└───────────────────────┬─────────────────────────────────┘
│ derived
┌───────────────────────▼─────────────────────────────────┐
│ Computed / Derived State (useMemo / Reselect) │
│ - expandedRecurrences: EventInstance[] │
│ - overlapGroups: OverlapGroup[] │
│ - conflictMap: Map<eventId, eventId[]> │
└─────────────────────────────────────────────────────────┘
│ local component state
┌───────────────────────▼─────────────────────────────────┐
│ Local / Component State (useState / useReducer) │
│ - form field values │
│ - hover state │
│ - tooltip visibility │
└─────────────────────────────────────────────────────────┘
User Action (click/drag)
│
▼
Component Handler
│
├── Optimistic UI update (local state mutated immediately)
│
├── API mutation dispatched (React Query mutate)
│
├── Server responds:
│ success → merge server response, clear dirty flag
│ failure → rollback optimistic update, show toast
│
└── WebSocket event arrives from other collaborator
→ merge into normalized cache
→ re-render affected view slices
Layout: A 6-row × 7-column CSS grid. Each cell is a DayCell.
[Mon] [Tue] [Wed] [Thu] [Fri] [Sat] [Sun]
[ 27] 28 29 30 31 [ 1] [ 2] ← greyed = outside current month
[ 3] 4 5 6 7 8 9
...
Challenges & solutions:
- Multi-day events span across cells. Solved with absolute positioning relative to the week row container, not individual cells. An event from Wed–Fri gets
left: 2/7 * 100%andwidth: 3/7 * 100%. - "More" overflow — a cell with > 3 events shows "+2 more" which opens a popover. Calculate visible event slots by measuring cell height at render time.
- Memoisation — each
WeekRowis wrapped inReact.memo. Rows only re-render if their set of events changes (shallow compare on event ID arrays).
Layout: Two zones stacked vertically:
- All-day banner — a separate scrollable row for events spanning > 24h or marked
allDay: true - Time grid — a scrollable 24-hour × 7-column grid
Mon 12 Tue 13 Wed 14 ...
all-day [Team meeting──────────]
8:00 [Standup]
8:30
9:00 [Design review]
...
Pixel-to-time mapping:
const HOUR_HEIGHT = 60; // px per hour at 100% zoom
function timeToY(datetime: Date, dayStart: Date): number {
const minutesFromDayStart = differenceInMinutes(datetime, dayStart);
return (minutesFromDayStart / 60) * HOUR_HEIGHT;
}
function yToTime(y: number, dayStart: Date): Date {
const minutes = Math.round((y / HOUR_HEIGHT) * 60);
return addMinutes(dayStart, minutes);
}Zoom levels: 100% (60px/hr), 150% (90px/hr), 200% (120px/hr). Zoom state is persisted in localStorage.
Identical to Week View but with a single column. The time grid takes full width, allowing longer event titles to show. The all-day banner handles the same logic.
A chronological flat list grouped by date. Infinite scroll loads future dates on demand (intersection observer on the sentinel element).
interface AgendaGroup {
date: Date; // Group header e.g. "Monday, June 12"
events: EventInstance[];
}
// Render pattern
<AgendaContainer>
{groups.map(group => (
<AgendaGroup key={group.date.toISOString()} date={group.date}>
{group.events.map(event => <AgendaItem key={event.instanceId} event={event} />)}
</AgendaGroup>
))}
<Sentinel ref={sentinelRef} /> {/* triggers load more */}
</AgendaContainer>The RFC 5545 iCalendar spec defines recurrence rules. Key properties:
| Property | Meaning | Example |
|---|---|---|
FREQ |
Frequency | WEEKLY |
INTERVAL |
Every N units of FREQ | INTERVAL=2 (biweekly) |
BYDAY |
Day filter | BYDAY=MO,FR |
BYMONTHDAY |
Day-of-month | BYMONTHDAY=1,-1 (first & last) |
UNTIL |
End date | UNTIL=20261231T235959Z |
COUNT |
Max occurrences | COUNT=10 |
EXDATE |
Exception dates | Specific dates to skip |
RDATE |
Extra dates | Additional occurrences added outside rule |
The key insight: The server returns only master events with RRULE strings. The frontend expands them into concrete instances for the visible date range. This is called "client-side expansion."
function expandRecurrence(
master: CalendarEvent,
viewStart: Date,
viewEnd: Date,
maxInstances = 300
): EventInstance[] {
const rule = parseRRule(master.recurrence!);
const instances: EventInstance[] = [];
const duration = differenceInMilliseconds(
parseISO(master.end.dateTime!),
parseISO(master.start.dateTime!)
);
let cursor = parseISO(master.start.dateTime!);
let count = 0;
while (cursor <= viewEnd && count < maxInstances) {
if (cursor >= viewStart && !isExcluded(cursor, rule.exDates)) {
instances.push(makeInstance(master, cursor, addMilliseconds(cursor, duration)));
}
cursor = nextOccurrence(cursor, rule); // Core: advance by FREQ × INTERVAL
if (rule.until && cursor > rule.until) break;
if (rule.count && count >= rule.count) break;
count++;
}
// Merge server-confirmed exceptions (edits to individual instances)
return mergeExceptions(instances, master.id);
}nextOccurrence implementation sketch:
function nextOccurrence(current: Date, rule: ParsedRRule): Date {
switch (rule.freq) {
case 'DAILY': return addDays(current, rule.interval ?? 1);
case 'WEEKLY': return nextWeeklyOccurrence(current, rule);
case 'MONTHLY': return nextMonthlyOccurrence(current, rule);
case 'YEARLY': return addYears(current, rule.interval ?? 1);
// ... etc
}
}
function nextWeeklyOccurrence(current: Date, rule: ParsedRRule): Date {
const days = rule.byDay ?? [toDayCode(current)];
// Find next matching day within the week, then skip by interval weeks
// ... implementation with getDay() comparisons
}Why client-side expansion?
- Reduces API chattiness — one request per calendar fetch, not per rendered week
- Enables instant navigation (no network round trip for prev/next week)
- Allows snappy drag-and-drop preview before committing
Caching expanded instances:
// Memoised with the master event ID + RRULE string + date range as cache key
const expansionCache = new LRUCache<string, EventInstance[]>({ max: 200 });
const cacheKey = `${master.id}:${master.recurrence?.join('|')}:${viewStart.toISOString()}:${viewEnd.toISOString()}`;When a user edits a recurring event instance, they get three choices:
1. This event only (exception)
- Create a new exception event with
recurringEventId = masterIdandoriginalStartTime = instance.start - The RRULE remains unchanged; EXDATE for the original slot is added if needed (when moving, not just editing)
Master: RRULE weekly on Mon
Instances: Mon1 Mon2 Mon3 [Mon4→Tue4 exception] Mon5
2. This and following events
- Truncate master's UNTIL to the day before the selected instance
- Create a new master event starting from the selected instance with the same RRULE, optionally modified
Master A: RRULE weekly on Mon, UNTIL=Mon3
New Master B: RRULE weekly on Mon, starting Mon4 (with edits applied)
3. All events
- Update the master event directly
- All exceptions that no longer deviate from the new master may be cleaned up
- Show conflict warning if this overwrites user-specific exceptions
Library choice: @dnd-kit/core (lighter than react-beautiful-dnd, touch-friendly, supports accessibility).
Architecture:
// DndContext wraps the entire TimeGrid
<DndContext
sensors={sensors} // PointerSensor + KeyboardSensor
collisionDetection={closestCorner}
onDragStart={handleDragStart}
onDragOver={handleDragOver} // Preview snapping
onDragEnd={handleDragEnd} // Commit or rollback
>
<TimeGrid>
{events.map(event => (
<Draggable key={event.instanceId} id={event.instanceId}>
<EventBlock event={event} />
</Draggable>
))}
</TimeGrid>
<DragOverlay> {/* Renders drag ghost */}
{draggingEvent && <EventBlock event={draggingEvent} isGhost />}
</DragOverlay>
</DndContext>Snapping logic:
function snapToSlot(y: number, snapMinutes = 15): Date {
const raw = yToTime(y, dayStart);
const mins = getMinutes(raw) + getHours(raw) * 60;
const snapped = Math.round(mins / snapMinutes) * snapMinutes;
return setHours(setMinutes(dayStart, snapped % 60), Math.floor(snapped / 60));
}Recurrence drag: When dragging an instance of a recurring event, immediately prompt the user with the edit-mode dialog (This / This & Following / All). Do not commit the drag until the user confirms.
Resize handle: The bottom of each EventBlock has a 6px drag handle. Dragging it adjusts event.end while keeping event.start fixed. Minimum event duration: 15 minutes.
Timezone handling is among the most subtle parts of a calendar frontend. Getting it wrong causes incorrect event display across DST boundaries or for guests in other zones.
Rules:
-
All-day events are timezone-agnostic. They are stored as
date: 'YYYY-MM-DD'without time or zone. They always render as the full day in the user's local view, regardless of what timezone they're in. -
Timed events are stored in UTC. Display converts to the user's preferred timezone using the IANA database.
-
DST (Daylight Saving Time) transitions: When an event that recurs weekly crosses a DST boundary, future instances should keep the same "clock time" (e.g. 9:00 AM), not the same UTC offset. This requires computing each instance's local time individually, not by adding fixed offsets.
// WRONG: adding fixed offset across DST
const nextInstance = addMilliseconds(instance, 7 * 24 * 60 * 60 * 1000);
// CORRECT: use date-fns-tz to work in the target timezone
import { zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';
function nextWeekSameLocalTime(instance: Date, tz: string): Date {
const localTime = utcToZonedTime(instance, tz);
const nextLocal = addWeeks(localTime, 1);
return zonedTimeToUtc(nextLocal, tz);
}-
Guest timezone display: On event detail view, show the event time in both the organizer's timezone and the viewer's timezone if they differ.
-
"Floating" times: All-day events spanning multiple days (multi-day events entered without times) should not have their end date shifted by UTC conversion. Store as
datenotdateTime.
Library: date-fns-tz (tree-shakeable, 40KB) or Temporal polyfill (future-proof, better API).
The agenda view uses virtual scrolling to handle thousands of events without rendering them all:
import { useVirtualizer } from '@tanstack/react-virtual';
const virtualizer = useVirtualizer({
count: flattenedItems.length,
getScrollElement: () => scrollRef.current,
estimateSize: (i) => flattenedItems[i].type === 'header' ? 40 : 64,
overscan: 10,
});Month view cells that are out of the visible viewport use content-visibility: auto CSS:
.month-row {
content-visibility: auto;
contain-intrinsic-size: 0 120px; /* estimated height */
}When multiple events overlap in the time grid, they must be laid out side-by-side. This is a classic interval scheduling / graph colouring problem.
Algorithm (O(n log n) sweep line):
interface LayoutColumn {
events: EventInstance[];
end: Date;
}
function computeOverlapLayout(events: EventInstance[]): Map<string, Layout> {
// 1. Sort events by start time, then by duration descending
const sorted = [...events].sort((a, b) =>
a.start - b.start || (b.end - b.start) - (a.end - a.start)
);
const columns: LayoutColumn[] = [];
const layoutMap = new Map<string, Layout>();
for (const event of sorted) {
// 2. Find the first column where the last event ends before this event starts
let placed = false;
for (let col = 0; col < columns.length; col++) {
if (columns[col].end <= event.start) {
columns[col].events.push(event);
columns[col].end = event.end;
layoutMap.set(event.instanceId, { column: col, totalColumns: 0 }); // fill totalColumns later
placed = true;
break;
}
}
if (!placed) {
columns.push({ events: [event], end: event.end });
layoutMap.set(event.instanceId, { column: columns.length - 1, totalColumns: 0 });
}
}
// 3. Second pass: determine max concurrent columns for each event
for (const event of sorted) {
const layout = layoutMap.get(event.instanceId)!;
let maxCol = layout.column;
for (const [otherId, otherLayout] of layoutMap) {
const other = eventsById.get(otherId)!;
if (other.start < event.end && other.end > event.start) {
maxCol = Math.max(maxCol, otherLayout.column);
}
}
layout.totalColumns = maxCol + 1;
}
return layoutMap;
}
// Render: left = (column / totalColumns) * 100%, width = (1 / totalColumns) * 100%// React Query config
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 min: don't refetch if fresh
gcTime: 30 * 60 * 1000, // 30 min: keep in memory
refetchOnWindowFocus: true,
retry: 2,
}
}
});
// Prefetch adjacent weeks on navigation
function prefetchAdjacentWeeks(date: Date) {
[-1, 1].forEach(offset => {
const adjacentWeek = addWeeks(date, offset);
queryClient.prefetchQuery({
queryKey: ['events', calendarId, getWeekRange(adjacentWeek)],
queryFn: () => fetchEvents(calendarId, getWeekRange(adjacentWeek))
});
});
}IndexedDB cache: Events are also persisted to IndexedDB via idb-keyval as the offline store. On first load, the app reads from IndexedDB (instant), then revalidates against the network in the background.
Sync strategy: Google Calendar uses a sync token approach:
// Initial full sync
const { items, nextSyncToken } = await calendarApi.list({ calendarId });
storeSyncToken(calendarId, nextSyncToken);
// Incremental sync (WebSocket message or polling)
const { items: changed, nextSyncToken: newToken } = await calendarApi.list({
calendarId,
syncToken: getSyncToken(calendarId)
});
// changed contains only added/modified/deleted events since last sync
applyIncrementalChanges(changed);
storeSyncToken(calendarId, newToken);Conflict resolution (Optimistic Concurrency Control):
Each event has an etag (server-assigned version hash) and a sequence (iCal integer).
- Client sends update with
If-Match: <etag>header - If server returns
412 Precondition Failed, another client modified the event - Strategy: last-write-wins for non-overlapping fields, merge-on-conflict for attendee lists
- If unresolvable: show conflict UI with diff viewer, let user pick version
WebSocket / SSE:
// Server-Sent Events for push updates (simpler than WebSocket for calendar)
const eventSource = new EventSource('/api/events/stream?token=...');
eventSource.addEventListener('event.updated', (e) => {
const updatedEvent = JSON.parse(e.data);
queryClient.setQueryData(
['events', updatedEvent.calendarId, currentRange],
(old: CalendarEvent[]) => mergeEvent(old, updatedEvent)
);
});
eventSource.addEventListener('event.deleted', (e) => {
const { id, calendarId } = JSON.parse(e.data);
queryClient.setQueryData(
['events', calendarId, currentRange],
(old: CalendarEvent[]) => old.filter(ev => ev.id !== id)
);
});Service Worker strategy (Workbox):
| Resource | Cache Strategy |
|---|---|
| App Shell (HTML, CSS, JS) | Cache First (version-busted on deploy) |
| Static assets (fonts, icons) | Stale While Revalidate |
| Calendar event API responses | Network First with fallback to IndexedDB |
| Profile images | Cache First with 24hr expiry |
// sw.ts
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
// API: network-first with IndexedDB fallback
registerRoute(
({ url }) => url.pathname.startsWith('/api/events'),
new NetworkFirst({
cacheName: 'events-cache',
plugins: [new ExpirationPlugin({ maxAgeSeconds: 60 * 60 * 24 })]
})
);Offline event creation:
// Queue mutations when offline
const mutation = useMutation({
mutationFn: createEvent,
onMutate: async (newEvent) => {
// 1. Optimistic update
queryClient.setQueryData(['events', ...], (old) => [...old, newEvent]);
return { previousEvents };
},
onError: (err, _, context) => {
if (!navigator.onLine) {
offlineQueue.push({ type: 'createEvent', payload: newEvent });
return; // Don't rollback — will sync when back online
}
queryClient.setQueryData(['events', ...], context.previousEvents);
}
});
// Flush queue on reconnect
window.addEventListener('online', () => flushOfflineQueue());The week/day time grid implements the ARIA grid role with row and gridcell roles:
<div role="grid" aria-label="Week of June 10–16">
<div role="row">
<div role="columnheader" aria-label="Monday June 10">Mon 10</div>
<!-- ... -->
</div>
<div role="row" aria-label="8:00 AM">
<div role="gridcell" aria-label="Monday June 10, 8:00 AM, empty">
<!-- Event blocks as buttons inside the cell -->
<button role="button" aria-label="Standup, 8:00 to 8:30 AM" tabindex="0">
Standup
</button>
</div>
</div>
</div>| Key | Action |
|---|---|
Arrow keys |
Navigate between time slots |
Enter / Space |
Open event detail |
n |
New event at focused slot |
e |
Edit focused event |
Delete / Backspace |
Delete focused event (confirm dialog) |
Escape |
Close modal / cancel drag |
t |
Jump to today |
1 / 2 / 3 |
Switch Day / Week / Month view |
- Opening the event modal traps focus inside (
focus-trap-react) - On modal close, focus returns to the triggering element
- Drag-and-drop has a keyboard mode: activate dragging with
Space, move with arrows, confirm withEnter, cancel withEscape
// aria-live region for dynamic announcements
const [announcement, setAnnouncement] = useState('');
// After drag completes:
setAnnouncement(`Moved "${event.title}" to Monday June 12 at 2:00 PM`);
// In render:
<div role="status" aria-live="polite" className="sr-only">
{announcement}
</div>GET /calendars/{calendarId}/events
?timeMin=&timeMax=&singleEvents=true&syncToken=
→ { items: Event[], nextSyncToken, nextPageToken }
GET /calendars/{calendarId}/events/{eventId}
→ Event
POST /calendars/{calendarId}/events
Body: Partial<Event>
→ Event (with server-assigned id, etag)
PUT /calendars/{calendarId}/events/{eventId}
Header: If-Match: <etag>
Body: Event
→ Event (with updated etag)
PATCH /calendars/{calendarId}/events/{eventId}
Body: Partial<Event> (field mask pattern)
→ Event
DELETE /calendars/{calendarId}/events/{eventId}
Header: If-Match: <etag>
→ 204 No Content
POST /calendars/{calendarId}/events/{eventId}/move
?destination={calendarId}
→ Event (in new calendar)
GET /freeBusy
Body: { timeMin, timeMax, items: [{ id: calendarId }] }
→ { calendars: { [calendarId]: { busy: Period[] } } }
type ServerEvent =
| { type: 'event.created'; calendarId: string; event: CalendarEvent }
| { type: 'event.updated'; calendarId: string; event: CalendarEvent }
| { type: 'event.deleted'; calendarId: string; eventId: string }
| { type: 'calendar.updated'; calendar: Calendar }
| { type: 'notification.due'; notification: Notification };E2E (Playwright, ~20 tests)
├── Create recurring event, verify next 4 weeks
├── Drag event across days, confirm API call
├── Offline create event, reconnect, verify sync
└── Timezone change, verify event time updated
Integration (React Testing Library, ~80 tests)
├── WeekView renders correct event positions
├── Recurrence engine produces correct instances
├── Drag-and-drop fires correct mutations
└── Edit modal shows correct edit-mode dialog for recurring
Unit (Vitest, ~200 tests)
├── parseRRule (all FREQ/BYDAY/BYSETPOS combos)
├── expandRecurrence (DST transitions, COUNT, UNTIL)
├── computeOverlapLayout (3-column cases, edge cases)
├── snapToSlot (15-min rounding)
└── mergeExceptions (exception overrides master instances)
describe('expandRecurrence', () => {
test('DAILY for 5 days from Jan 1', () => { /* count=5 */ });
test('WEEKLY on MO,WE,FR', () => { /* byDay filter */ });
test('last Friday of month for 3 months', () => { /* BYDAY=-1FR */ });
test('excludes EXDATE entries', () => { /* skip specific date */ });
test('handles spring-forward DST', () => { /* 2:00 AM → 3:00 AM */ });
test('UNTIL truncates correctly', () => { /* doesn't include UNTIL date */ });
test('merges server exception that moved instance', () => { /* override */ });
});Questions you will almost certainly be asked, and how to nail them:
Start with data model → state layers → component tree → rendering strategies. Use the tier structure: server state (React Query) → global UI state (Zustand) → derived state (useMemo) → local state (useState).
- Server stores master + RRULE string only. Frontend expands instances for the visible range.
- Explain the sweep through the RRULE (FREQ → INTERVAL → BYDAY filters).
- Mention EXDATE for exceptions and the three edit modes.
- Show the cache key design to avoid redundant expansions.
Describe the O(n log n) sweep line / interval scheduling algorithm. Key: sort by start time, assign to first available column (no overlap), second pass to count total concurrent columns, then set CSS left and width as percentages.
- Virtual scrolling for agenda view
content-visibility: autofor off-screen month rows- React.memo + stable event ID arrays as props
- Prefetch adjacent week/month on navigation
- IndexedDB for instant cold-start load
- Code-split each view (Day/Week/Month/Agenda are separate bundles)
Stress two points: (1) all-day events are date-only, never convert to UTC, (2) weekly recurring events across DST boundaries must compute each instance's local time independently, not add fixed milliseconds.
Service Worker (Workbox) caches API responses. Mutations while offline are queued in localStorage / IndexedDB. On reconnect, the offline queue is flushed in order. Conflict detection via etag + sequence.
Lead with the 60fps constraint for drag-and-drop, then < 3s TTI, then < 2s real-time sync. Always quantify.
- Push RRULE expansion to the server with pagination (cursor-based)
- Switch to a canvas-based rendering engine for the time grid (like Google Calendar itself does for performance at scale)
- Use a CRDT (e.g. Yjs) instead of OCC for true real-time collaborative editing
This document covers the frontend system design comprehensively enough to pass system design rounds at Microsoft, Amazon, Uber, Google, Meta, and similar companies. Focus on the data model and recurrence engine — they are the most differentiating areas that separate strong candidates from the rest.