A deep-dive reference for designing a production-grade calendar application — covering data modelling, architecture, event rendering, APIs, and optimization.
- Overview
- Functional Requirements
- Non-Functional Requirements
- Data Modelling
- High-Level Architecture
- View System Design
- Event Rendering Techniques
- Drag & Drop System
- Recurrence Engine
- API Interfaces
- Caching & Data Fetching Strategy
- Performance Optimizations
- Accessibility (a11y)
- Offline Support & PWA
- Security Considerations
- Scalability & Edge Cases
- Testing Strategy
- Observability & Monitoring
A Google Calendar clone is a deceptively complex frontend system. On the surface it's a grid of colored boxes. Underneath, it demands:
- Precise temporal reasoning across timezones, DST, and recurrence rules
- Dense visual layouts with non-overlapping event collision resolution
- Real-time collaboration with optimistic UI and conflict resolution
- High performance even with thousands of events rendered across views
This document walks through every layer of the system — from raw data models to rendering algorithms to API boundaries — as you would design it in a senior frontend system design interview or an actual production build.
| # | Feature | Description |
|---|---|---|
| F1 | Multiple Views | Day, Week, Month, and Agenda/List views |
| F2 | Event CRUD | Create, read, update, delete calendar events |
| F3 | Event Details | Title, description, start/end datetime, location, color, guests, attachments |
| F4 | Recurring Events | Support RRULE patterns: daily, weekly, monthly, yearly, custom |
| F5 | Multi-calendar | Multiple calendars per user (work, personal, shared, holidays) |
| F6 | Drag & Drop | Drag events to reschedule; resize to change duration |
| F7 | Search | Full-text event search across past and future |
| F8 | Timezone Support | Display events in any timezone; detect user's local timezone |
| F9 | Sharing & Collaboration | Share calendars, invite guests, show guest RSVP status |
| F10 | Notifications | Browser notifications and in-app reminders |
| F11 | Import/Export | Import .ics files; export events to .ics |
| F12 | Keyboard Navigation | Full keyboard shortcuts (n = next, p = prev, t = today, 1/2/3/4 = view change) |
- Google Meet / video conferencing link generation
- Smart scheduling ("Find a time" based on attendees' free/busy)
- Event color coding and categories
- Birthdays and task integration
- Mini-calendar side panel
| Metric | Target |
|---|---|
| First Contentful Paint (FCP) | < 1.5s |
| Time to Interactive (TTI) | < 3s |
| View switch latency | < 100ms (with cached data) |
| Event render (1000 events) | < 16ms frame budget |
| API response (event fetch) | < 200ms (p95) |
- Availability: 99.9% uptime (< 8.7h downtime/year)
- Offline: Read-only access to cached events; queued writes synced on reconnect
- Optimistic UI: UI updates instantly; rolls back on API failure
- Handle users with 10,000+ events across 5+ years of history
- 50+ calendars (shared team calendars, holiday feeds)
- Real-time sync for 10–20 concurrent collaborators on a shared calendar
- Modern browsers: Chrome, Firefox, Safari, Edge (last 2 major versions)
- Mobile-responsive down to 375px wide
- WCAG 2.1 AA accessibility compliance
┌─────────────────────┐ ┌──────────────────────┐
│ User │ │ Calendar │
├─────────────────────┤ ├──────────────────────┤
│ id: string │1─────N│ id: string │
│ email: string │ │ ownerId: string │
│ displayName: string │ │ name: string │
│ photoUrl: string │ │ color: string │
│ timezone: string │ │ isVisible: boolean │
│ settings: UserPrefs │ │ accessRole: ACLRole │
└─────────────────────┘ └──────────────────────┘
│
│ 1─────N
▼
┌──────────────────────────┐
│ Event │
├──────────────────────────┤
│ id: string │
│ calendarId: string │
│ title: string │
│ description: string │
│ start: EventDateTime │
│ end: EventDateTime │
│ isAllDay: boolean │
│ recurrence: string[] │ ← RRULE strings
│ recurringEventId?: string │ ← parent ID
│ originalStartTime?: DT │ ← exception key
│ status: EventStatus │
│ color?: string │
│ location?: string │
│ attendees: Attendee[] │
│ reminders: Reminder[] │
│ attachments: Attachment[] │
│ creator: EventActor │
│ organizer: EventActor │
│ etag: string │ ← optimistic lock
│ updated: string │ ← ISO 8601
└──────────────────────────┘
// ─── Primitives ─────────────────────────────────────────────
type ISODateTime = string; // "2025-08-14T09:00:00+05:30"
type ISODate = string; // "2025-08-14"
type HexColor = string; // "#3788d8"
// ─── Event DateTime ─────────────────────────────────────────
interface EventDateTime {
dateTime?: ISODateTime; // for timed events
date?: ISODate; // for all-day events
timeZone?: string; // IANA tz identifier, e.g. "Asia/Kolkata"
}
// ─── Attendee ───────────────────────────────────────────────
type RSVPStatus = 'accepted' | 'declined' | 'tentative' | 'needsAction';
interface Attendee {
email: string;
displayName?: string;
self?: boolean; // true if this is the requesting user
organizer?: boolean;
responseStatus: RSVPStatus;
optional?: boolean;
}
// ─── Reminder ───────────────────────────────────────────────
type ReminderMethod = 'email' | 'popup';
interface Reminder {
method: ReminderMethod;
minutes: number; // minutes before event start
}
// ─── Event ──────────────────────────────────────────────────
type EventStatus = 'confirmed' | 'tentative' | 'cancelled';
type EventVisibility = 'default' | 'public' | 'private' | 'confidential';
interface CalendarEvent {
id: string;
calendarId: string;
title: string; // "summary" in Google API
description?: string;
location?: string;
start: EventDateTime;
end: EventDateTime;
isAllDay: boolean;
recurrence?: string[]; // ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR"]
recurringEventId?: string; // parent series ID for exceptions
originalStartTime?: EventDateTime;
status: EventStatus;
visibility: EventVisibility;
color?: HexColor;
calendarColor?: HexColor; // resolved fallback from Calendar
attendees: Attendee[];
reminders: { useDefault: boolean; overrides?: Reminder[] };
attachments?: Attachment[];
creator: EventActor;
organizer: EventActor;
htmlLink: string;
etag: string;
created: ISODateTime;
updated: ISODateTime;
// ── Client-only fields ──
_isPlaceholder?: boolean; // optimistic insert
_computedLane?: number; // collision resolution result
_computedLaneCount?: number;
}
// ─── Calendar ───────────────────────────────────────────────
type ACLRole = 'none' | 'freeBusyReader' | 'reader' | 'writer' | 'owner';
interface Calendar {
id: string;
summary: string;
description?: string;
timeZone: string;
color: HexColor;
backgroundColor: HexColor;
foregroundColor: HexColor;
accessRole: ACLRole;
selected: boolean;
primary?: boolean;
}Recurring events follow RFC 5545 iCalendar RRULE format:
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20251231T235959Z
RRULE:FREQ=MONTHLY;BYDAY=2MO ← 2nd Monday of every month
RRULE:FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1 ← Jan 1st every year
RRULE:FREQ=DAILY;INTERVAL=2;COUNT=10 ← every other day, 10 times
Expansion Strategy: Never store expanded instances. Generate them on-demand using a library like rrule.js. Cache the expanded set per viewport window.
import { RRule, RRuleSet } from 'rrule';
function expandRecurringEvent(
event: CalendarEvent,
windowStart: Date,
windowEnd: Date
): CalendarEvent[] {
if (!event.recurrence?.length) return [event];
const set = new RRuleSet();
for (const rule of event.recurrence) {
if (rule.startsWith('RRULE:')) set.rrule(RRule.fromString(rule));
if (rule.startsWith('EXDATE:')) set.exdate(parseExDate(rule));
}
return set
.between(windowStart, windowEnd, true)
.map((occurrenceDate, i) => ({
...event,
id: `${event.id}_${occurrenceDate.toISOString()}`,
recurringEventId: event.id,
originalStartTime: { dateTime: occurrenceDate.toISOString() },
start: offsetEventDateTime(event.start, occurrenceDate),
end: offsetEventDateTime(event.end, occurrenceDate),
}));
}┌─────────────────────────────────────────────────────────────────┐
│ Browser (Client) │
│ │
│ ┌───────────┐ ┌──────────────────────────────────────────┐ │
│ │ Router │ │ App Shell │ │
│ │ /calendar │ │ Sidebar │ Header (Nav/View Toggle) │ │
│ │ /event │ ├───────────┴──────────────────────────────┤ │
│ └───────────┘ │ Calendar Viewport │ │
│ │ ┌──────────────────────────────────────┐│ │
│ │ │ View Controller (Day/Week/Month/ ││ │
│ │ │ Agenda) ─ driven by URL params ││ │
│ │ │ ┌──────────────────────────────────┐ ││ │
│ │ │ │ Time Grid / Month Grid │ ││ │
│ │ │ │ EventLayer (collision-resolved) │ ││ │
│ │ │ │ DragDropLayer │ ││ │
│ │ │ └──────────────────────────────────┘ ││ │
│ │ └──────────────────────────────────────┘│ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ State Layer │ │
│ │ CalendarStore EventStore UIStore AuthStore │ │
│ │ (Zustand / Redux Toolkit / Jotai) │ │
│ └────────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────▼────────────────────────────────┐ │
│ │ Data / Sync Layer │ │
│ │ React Query / SWR ── API Client ── WS Client │ │
│ │ IndexedDB (offline cache) ── Service Worker │ │
│ └────────────────────────┬────────────────────────────────┘ │
└───────────────────────────┼─────────────────────────────────────┘
│ HTTPS / WSS
┌───────────────────────────▼─────────────────────────────────────┐
│ Backend Services │
│ API Gateway ── Calendar Service ── Notification Service │
│ Auth Service ── Search Service ── Recurring Engine │
│ PostgreSQL ── Redis (pub/sub) ── Elasticsearch │
└─────────────────────────────────────────────────────────────────┘
<App>
<AuthProvider>
<ThemeProvider>
<Router>
<AppShell>
<Sidebar>
<MiniCalendar /> ← small nav calendar
<CalendarList /> ← toggleable calendars
<CreateEventButton />
</Sidebar>
<MainContent>
<CalendarHeader>
<DateNavigation /> ← prev/next/today
<ViewToggle /> ← day/week/month/agenda
<SearchBar />
<SettingsMenu />
</CalendarHeader>
<CalendarViewport>
{view === 'day' && <DayView />}
{view === 'week' && <WeekView />}
{view === 'month' && <MonthView />}
{view === 'agenda' && <AgendaView />}
</CalendarViewport>
</MainContent>
</AppShell>
<EventModal /> ← portal, renders above all
<EventPopover /> ← quick-view popover
</Router>
</ThemeProvider>
</AuthProvider>
</App>
Use Zustand slices (or Redux Toolkit slices) with React Query for server state:
// ── UI State (client-only) ───────────────────────────────────────
interface UIStore {
currentView: 'day' | 'week' | 'month' | 'agenda';
focusedDate: Date;
selectedEventId: string | null;
isDragging: boolean;
// actions
setView(view: ViewType): void;
navigateDate(delta: number): void;
selectEvent(id: string | null): void;
}
// ── Calendar State ───────────────────────────────────────────────
interface CalendarStore {
calendars: Map<string, Calendar>;
visibleCalendarIds: Set<string>;
toggleCalendarVisibility(id: string): void;
}
// ── Event State — largely managed by React Query ─────────────────
// useEvents(startDate, endDate) → paginated + expanded recurring
// useEvent(id) → single event details
// useMutateEvent() → create/update/delete with optimistic updatesA single day rendered as a 24-hour vertical time grid.
┌──────────────────────────────────┐
│ all-day zone (sticky header) │ ← fixed height, scrolls independently
├────┬─────────────────────────────┤
│ 6 │ │ ← 1 column = 1 hour = HOUR_HEIGHT px
│ │ ███████████████ │ ← Event: top/height from start/end
│ 7 │ Meeting (9:00–10:00) │
│ │ ███████████████ │
│ 8 │ ████████████████ │ ← Overlapping event: lane system
│ │ Standup (8:30–9) │
└────┴─────────────────────────────┘
Seven day columns, each with the same time grid. The most complex view.
interface WeekViewProps {
weekStart: Date; // Monday or Sunday based on locale
events: CalendarEvent[];
}
// Layout algorithm:
// 1. Partition events by day column
// 2. Within each column, run collision detection
// 3. Assign lane (column subset) to each overlapping event group
// 4. Position each event: left = (lane / laneCount) * 100%
// width = (1 / laneCount) * 100%A 6-row × 7-column grid. Events are rendered as compact chips.
- Maximum N visible events per cell (typically 3–4), with "+X more" overflow
- Multi-day events span columns using absolute positioning in an overlay layer
- "Filler" days from prev/next month shown with reduced opacity
A flat, chronological list of upcoming events grouped by date. Ideal for mobile and accessibility.
interface AgendaGroup {
date: ISODate;
events: CalendarEvent[];
}
// Virtualised with react-window or tanstack-virtual for long listsThis is the most algorithmically interesting part of the system.
Every timed event maps to CSS top + height within its column:
const HOUR_HEIGHT = 60; // px per hour (configurable)
const DAY_START = 0; // 0 = midnight, 6 = 6 AM
function eventStyle(event: CalendarEvent): CSSProperties {
const startMins = getMinutesSinceDayStart(event.start);
const endMins = getMinutesSinceDayStart(event.end);
const duration = Math.max(endMins - startMins, 30); // min render height
return {
position: 'absolute',
top: `${(startMins / 60) * HOUR_HEIGHT}px`,
height: `${(duration / 60) * HOUR_HEIGHT}px`,
};
}
function getMinutesSinceDayStart(dt: EventDateTime): number {
const d = parseToLocalDate(dt); // respects timezone
return d.getHours() * 60 + d.getMinutes();
}When events overlap in time, they must be laid out side-by-side:
interface EventGroup {
events: CalendarEvent[];
laneCount: number;
}
/**
* Groups overlapping events into collision clusters,
* assigns each a lane index for horizontal layout.
*/
function resolveCollisions(events: CalendarEvent[]): CalendarEvent[] {
// 1. Sort by start time, then by duration (longer first)
const sorted = [...events].sort((a, b) => {
const startDiff = getStart(a) - getStart(b);
return startDiff !== 0 ? startDiff : getDuration(b) - getDuration(a);
});
const columns: CalendarEvent[][] = [];
for (const event of sorted) {
// Find the first column where the last event doesn't overlap
let placed = false;
for (let col = 0; col < columns.length; col++) {
const last = columns[col].at(-1)!;
if (getEnd(last) <= getStart(event)) {
columns[col].push(event);
event._computedLane = col;
placed = true;
break;
}
}
if (!placed) {
event._computedLane = columns.length;
columns.push([event]);
}
}
// 2. Determine max concurrent lanes per event (for width calc)
for (const event of sorted) {
const overlapping = sorted.filter(
e => e !== event && overlaps(event, e)
);
event._computedLaneCount = Math.max(
event._computedLane! + 1,
...overlapping.map(e => (e._computedLane ?? 0) + 1)
);
}
return sorted;
}
// CSS from lane data:
function laneStyle(event: CalendarEvent, gapPx = 2): CSSProperties {
const lane = event._computedLane ?? 0;
const total = event._computedLaneCount ?? 1;
return {
left: `calc(${(lane / total) * 100}% + ${gapPx}px)`,
width: `calc(${(1 / total) * 100}% - ${gapPx * 2}px)`,
};
}All-day events live in a separate sticky header zone above the time grid. They're sorted by duration (longer = higher priority = top row) and assigned rows when they overlap.
function layoutAllDayEvents(
events: CalendarEvent[],
weekStart: Date
): AllDayEventLayout[] {
const rows: CalendarEvent[][] = [];
const sorted = [...events].sort(
(a, b) => getDaySpan(b) - getDaySpan(a)
);
for (const event of sorted) {
let rowIndex = rows.findIndex(row =>
!row.some(e => dayRangesOverlap(e, event))
);
if (rowIndex === -1) {
rowIndex = rows.length;
rows.push([]);
}
rows[rowIndex].push(event);
const startCol = daysBetween(weekStart, getStartDate(event));
const span = Math.min(getDaySpan(event), 7 - startCol);
layouts.push({ event, row: rowIndex, startCol, span });
}
return layouts;
}In month view, multi-day events are rendered in an absolutely-positioned overlay grid that floats above the day cells:
┌──────────────────────────────────────────────────────────┐
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
├──────────────────────────────────────────────────────────┤
│ ███████████████████████████████ Conference (Mon–Thu) │
│ │ ████ Solo standup │
│ 12 │ 13 │ 14 │ 15 │ 16 │ 17 │ 18 │
└──────────────────────────────────────────────────────────┘
Position: left = (startDayIndex / 7) * 100%
width = (spanDays / 7) * 100%
top = rowIndex * EVENT_ROW_HEIGHT
// Week/Day View — absolute positioning in time column
const top = (startMinutes / (24 * 60)) * GRID_HEIGHT;
const height = (durationMins / (24 * 60)) * GRID_HEIGHT;
const left = (lane / laneCount) * COLUMN_WIDTH;
const width = (1 / laneCount) * COLUMN_WIDTH - GAP;
// Month View — CSS grid, events as overlay
const gridColumnStart = dayOfWeek + 1; // 1-indexed CSS grid
const gridColumnEnd = gridColumnStart + span;User grabs event
│
▼
onDragStart: snapshot original position + create ghost element
│
▼
onDragOver (throttled @ 60fps via requestAnimationFrame):
- compute target date/time from cursor position
- snap to 15-minute increments
- show drop preview (placeholder event with dashed border)
│
▼
onDrop:
- optimistically update UI
- fire PATCH /events/{id} with new start/end
- on error: rollback to snapshot + show toast
Implementation Notes:
- Use the HTML5 Drag and Drop API or a library like
dnd-kitfor accessibility compliance - Touch support requires
pointer eventsapproach (not native DnD) - Resize handles at the bottom of each event modify
endtime only - Snap granularity: 15 min for drag, 5 min for resize
function snapToGrid(minutes: number, snapMinutes = 15): number {
return Math.round(minutes / snapMinutes) * snapMinutes;
}
function cursorToTime(
cursorY: number,
gridRef: HTMLElement,
gridStartHour = 0
): Date {
const rect = gridRef.getBoundingClientRect();
const relY = cursorY - rect.top + gridRef.scrollTop;
const minutes = (relY / HOUR_HEIGHT) * 60 + gridStartHour * 60;
return minutesToDate(snapToGrid(minutes));
}When editing a recurring event, present three options:
┌──────────────────────────────────────┐
│ Edit recurring event │
│ │
│ ○ This event │ → creates exception (EXDATE + new instance)
│ ○ This and following events │ → splits RRULE UNTIL; creates new series
│ ○ All events │ → modifies the master event
└──────────────────────────────────────┘
// Modifying a single instance:
// 1. Add EXDATE to the master event
// 2. Create a new standalone event with recurringEventId pointing to master
// Master event gets:
EXDATE:20250815T090000Z
// New exception event:
{
recurringEventId: "master_id",
originalStartTime: { dateTime: "2025-08-15T09:00:00Z" },
start: { dateTime: "2025-08-15T10:00:00Z" }, // moved to 10 AM
...rest
}When expanding recurrences near DST transitions, always expand in the event's declared timezone (not UTC or local), then convert for display:
import { RRule } from 'rrule';
const rule = new RRule({
freq: RRule.WEEKLY,
dtstart: new Date('2025-01-06T09:00:00'), // local time, no Z suffix
tzid: 'America/New_York',
byday: [RRule.MO, RRule.WE, RRule.FR],
until: new Date('2025-12-31T09:00:00'),
});The API closely mirrors the Google Calendar API v3 shape:
GET /calendars/{calendarId}/events
?timeMin=2025-08-01T00:00:00Z
&timeMax=2025-08-31T23:59:59Z
&singleEvents=true ← expand recurrences
&orderBy=startTime
&maxResults=250
&pageToken={token} ← cursor pagination
POST /calendars/{calendarId}/events
PATCH /calendars/{calendarId}/events/{eventId}
PUT /calendars/{calendarId}/events/{eventId}
DELETE /calendars/{calendarId}/events/{eventId}
?sendUpdates=all|externalOnly|none
POST /batch
Content-Type: multipart/mixed; boundary=batch_boundary
--batch_boundary
Content-Type: application/http
GET /calendars/primary/events?timeMin=...
--batch_boundary
Content-Type: application/http
GET /calendars/work@group.calendar.google.com/events?timeMin=...
--batch_boundary--
GET /users/me/calendarList
POST /users/me/calendarList ← subscribe to calendar
PATCH /users/me/calendarList/{id} ← update color/visibility
DELETE /users/me/calendarList/{id} ← unsubscribe
interface EventListResponse {
kind: 'calendar#events';
summary: string;
updated: ISODateTime;
items: CalendarEvent[];
nextPageToken?: string; // cursor for next page
nextSyncToken?: string; // use for incremental sync
}// First full sync
const res1 = await fetchEvents({ timeMin, timeMax });
const { items, nextSyncToken } = res1;
// Save nextSyncToken. On next poll:
const res2 = await fetchEvents({ syncToken: nextSyncToken });
// res2.items contains ONLY changed/deleted events since last sync
// deleted events have status: 'cancelled'For clients needing flexible queries (e.g., mobile apps):
type Query {
calendars: [Calendar!]!
events(
calendarId: ID!
timeMin: DateTime!
timeMax: DateTime!
singleEvents: Boolean = true
): EventConnection!
event(calendarId: ID!, eventId: ID!): Event
}
type Mutation {
createEvent(calendarId: ID!, input: EventInput!): Event!
updateEvent(calendarId: ID!, eventId: ID!, input: EventInput!): Event!
deleteEvent(calendarId: ID!, eventId: ID!, sendUpdates: SendUpdates): Boolean!
moveEvent(eventId: ID!, from: ID!, to: ID!): Event!
}
type Subscription {
eventChanged(calendarId: ID!): EventChangePayload!
}For collaborative calendars, use WebSocket push notifications:
// Server → Client messages
type ServerMessage =
| { type: 'EVENT_CREATED'; payload: CalendarEvent }
| { type: 'EVENT_UPDATED'; payload: Partial<CalendarEvent> & { id: string } }
| { type: 'EVENT_DELETED'; payload: { id: string; calendarId: string } }
| { type: 'CALENDAR_UPDATED'; payload: Partial<Calendar> & { id: string } }
| { type: 'SYNC_REQUIRED'; payload: { syncToken: string } };
// Client subscribes by calendar
ws.send(JSON.stringify({
action: 'SUBSCRIBE',
calendarIds: ['primary', 'work@group.calendar.google.com'],
}));Alternatively, use Server-Sent Events (SSE) for simpler one-way push:
GET /calendars/watch
Accept: text/event-stream
data: {"type":"EVENT_UPDATED","payload":{...}}
data: {"type":"EVENT_DELETED","payload":{"id":"abc123"}}
Never fetch all historical events. Fetch per viewport window + buffer:
function getViewportWindow(view: ViewType, focusDate: Date) {
switch (view) {
case 'day': return { start: startOfDay(focusDate),
end: endOfDay(focusDate) };
case 'week': return { start: startOfWeek(focusDate),
end: endOfWeek(focusDate) };
case 'month': return { start: startOfMonth(focusDate),
end: addDays(endOfMonth(focusDate), 7) }; // show trailing week
}
}
// Pre-fetch adjacent windows on idle
requestIdleCallback(() => prefetchAdjacentWindows(view, focusDate));const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 min — events rarely change
gcTime: 30 * 60 * 1000, // 30 min cache retention
refetchOnWindowFocus: true,
retry: 2,
},
},
});
function useEvents(calendarId: string, start: Date, end: Date) {
return useQuery({
queryKey: ['events', calendarId, start.toISOString(), end.toISOString()],
queryFn: () => fetchEvents({ calendarId, timeMin: start, timeMax: end }),
placeholderData: keepPreviousData, // no flicker on date navigation
});
}const mutation = useMutation({
mutationFn: (event: CalendarEvent) => api.updateEvent(event),
onMutate: async (updated) => {
await queryClient.cancelQueries({ queryKey: ['events'] });
const previous = queryClient.getQueryData(['events', ...]);
// Optimistically update cache
queryClient.setQueryData(['events', ...], (old) =>
old.map(e => e.id === updated.id ? updated : e)
);
return { previous }; // snapshot for rollback
},
onError: (err, updated, context) => {
queryClient.setQueryData(['events', ...], context.previous);
toast.error('Failed to update event. Changes reverted.');
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['events'] }),
});// Use Dexie.js for a clean IndexedDB API
const db = new Dexie('CalendarDB');
db.version(1).stores({
events: '++id, calendarId, start.dateTime, end.dateTime, updated',
calendars: '++id',
syncState: '++id, calendarId, syncToken',
});
// Service Worker intercepts API calls:
// ONLINE → fetch from network, write-through to IndexedDB
// OFFLINE → serve from IndexedDB, queue mutations in outboxFor Agenda view or search results with 1000s of events, use virtual lists:
import { useVirtualizer } from '@tanstack/react-virtual';
function AgendaList({ events }: { events: CalendarEvent[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: events.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72, // px per event row estimate
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: '100%', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(item => (
<div
key={item.index}
style={{ transform: `translateY(${item.start}px)` }}
>
<EventRow event={events[item.index]} />
</div>
))}
</div>
</div>
);
}// Expensive: recurrence expansion + collision resolution
const resolvedEvents = useMemo(
() => resolveCollisions(expandAllRecurring(rawEvents, viewStart, viewEnd)),
[rawEvents, viewStart.toISOString(), viewEnd.toISOString()]
);
// Event chip component: skip re-render if event data unchanged
const EventChip = memo(({ event }: { event: CalendarEvent }) => {
return <div style={eventStyle(event)}>{event.title}</div>;
}, (prev, next) => prev.event.etag === next.event.etag);// Split heavy date/rrule libraries into async chunks
const { RRule } = await import(/* webpackChunkName: "rrule" */ 'rrule');
// Lazy-load views
const MonthView = lazy(() => import('./views/MonthView'));
const AgendaView = lazy(() => import('./views/AgendaView'));
// Lazy-load event modal (large form)
const EventModal = lazy(() => import('./modals/EventModal'));Apply contain: strict on calendar grid cells to limit browser layout recalculation scope:
.calendar-cell {
contain: strict; /* layout, paint, size all contained */
content-visibility: auto; /* skip off-screen cells entirely */
}
.time-grid-column {
contain: layout paint;
will-change: transform; /* promote to GPU layer during drag */
}// Throttle scroll handler to once per frame
const handleScroll = useCallback(
throttle(() => syncTimeIndicator(), 16),
[]
);
// Debounce search input
const handleSearch = useMemo(
() => debounce((q: string) => searchEvents(q), 300),
[]
);<div role="grid" aria-label="Calendar week view" aria-rowcount={25}>
<div role="row">
{days.map(day => (
<div role="columnheader" aria-label={format(day, 'EEEE, MMMM d')}>
{format(day, 'EEE')}
<span aria-hidden="true">{format(day, 'd')}</span>
</div>
))}
</div>
{hours.map(hour => (
<div role="row" aria-label={format(hour, 'h a')}>
{days.map(day => (
<div
role="gridcell"
aria-label={`${format(day, 'EEEE')} ${format(hour, 'h a')}`}
onClick={() => openNewEventDialog(day, hour)}
onKeyDown={e => e.key === 'Enter' && openNewEventDialog(day, hour)}
tabIndex={0}
/>
))}
</div>
))}
</div>
// Events as interactive elements
<button
role="button"
aria-label={`${event.title}, ${formatEventTime(event)}`}
aria-haspopup="dialog"
aria-expanded={isOpen}
onClick={() => openEventDetail(event)}
>
<span aria-hidden="true">{event.title}</span>
</button>| Key | Action |
|---|---|
t |
Go to today |
n |
Next period |
p |
Previous period |
1 |
Day view |
2 |
Week view |
3 |
Month view |
4 |
Agenda view |
c |
Create new event |
Esc |
Close modal/popover |
Tab |
Navigate between events |
Enter |
Open event details |
Delete |
Delete focused event |
┌─────────────────────────────────┐
│ Service Worker │
│ │
│ Cache Strategy: │
│ • App shell → Cache First │
│ • Event API → Network First │
│ (fallback to IndexedDB) │
│ • Static assets → Stale While │
│ Revalidate │
│ │
│ Background Sync: │
│ • Outbox queue for mutations │
│ • Retry on reconnect │
└─────────────────────────────────┘
// Queue mutation when offline
async function queueMutation(mutation: PendingMutation) {
await db.outbox.add({ ...mutation, timestamp: Date.now() });
await navigator.serviceWorker.ready;
await registration.sync.register('sync-calendar-mutations');
}
// SW sync handler
self.addEventListener('sync', event => {
if (event.tag === 'sync-calendar-mutations') {
event.waitUntil(flushOutbox());
}
});| Concern | Mitigation |
|---|---|
| Auth | OAuth 2.0 with PKCE; short-lived access tokens + refresh tokens; tokens in httpOnly cookies, never localStorage |
| XSS | Sanitize all user content (event title/description) with DOMPurify before rendering; CSP headers |
| CSRF | SameSite cookies; CSRF token on state-mutating requests |
| Data leakage | Calendar permissions checked server-side; ACL enforced per calendar, not just per event |
| Event injection | Validate RRULE strings server-side; cap recurrence expansion count (e.g., max 1,000 instances) |
| Rate limiting | API gateway rate limits per user; exponential backoff on 429 responses |
| Content Security Policy | default-src 'self'; script-src 'self' 'nonce-{...}' — no inline scripts |
| Edge Case | Handling Strategy |
|---|---|
| Midnight-spanning events | Clip to 24:00 in day view; show continuation indicator next day |
| Short events (< 30 min) | Enforce minimum render height (30 min); show truncated title |
| Overlapping all-day events | Row-based stacking with max row limit + overflow count |
| Event at exactly midnight | Treat as start of day (00:00), not end of previous day |
| DST transitions | Always expand recurrences in event's declared IANA timezone |
| Leap year Feb 29 | YEARLY recurrence skips Feb 29 in non-leap years unless BYMONTHDAY=-1 |
| Very long event titles | Truncate with ellipsis; full title in aria-label and tooltip |
| Events spanning weeks | Split rendering at week boundary; show continuation arrows |
| 1000+ events in month | Virtual scroll in overflow popover; server-side count aggregation |
| Calendar feed deleted | Gracefully show "Calendar unavailable" placeholder; retain events in read-only |
// Principle: store everything in UTC; display in user's preferred timezone
function displayTime(event: CalendarEvent, userTz: string): string {
const dt = parseISO(event.start.dateTime!);
return formatInTimeZone(dt, userTz, 'h:mm a');
}
// Multi-timezone support (for travelers / international teams)
interface AppSettings {
primaryTimezone: string; // "Asia/Kolkata"
additionalTimezones?: string[]; // ["America/New_York", "Europe/London"]
}
// Render additional timezone labels on the left of the time gridresolveCollisions()— various overlapping permutationsexpandRecurringEvent()— RRULE edge cases (DST, leap year, EXDATE)snapToGrid()— boundary conditionsgetMinutesSinceDayStart()— timezone-aware
- Create event flow: open modal → fill form → submit → event appears in grid
- Drag & drop: simulate pointer events → verify event moved to new time
- View navigation: click next/prev → verify correct date range rendered
- Recurrence edit: "this event only" vs "all events" — verify correct API calls
test('create recurring weekly event', async ({ page }) => {
await page.goto('/calendar');
await page.click('[data-testid="create-event-btn"]');
await page.fill('[name="title"]', 'Team Standup');
await page.check('[name="repeat"]');
await page.selectOption('[name="freq"]', 'WEEKLY');
await page.click('[data-testid="save-event"]');
// Navigate to next week
await page.click('[data-testid="next-period"]');
await expect(page.locator('text=Team Standup')).toBeVisible();
});Stories for:
- EventChip in all states (normal, past, all-day, multi-day, tentative, cancelled)
- Collision layouts (2-up, 3-up, 4-up)
- Month view with overflow (+N more)
- Loading and empty states
// Core Web Vitals
import { onCLS, onFID, onLCP } from 'web-vitals';
onLCP(metric => analytics.track('web_vital', { name: 'LCP', value: metric.value }));
// Custom performance marks
performance.mark('view-switch-start');
// ... after render:
performance.measure('view-switch-duration', 'view-switch-start');
const [measure] = performance.getEntriesByName('view-switch-duration');
analytics.track('view_switch', { view: newView, duration: measure.duration });// Sentry integration
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.1,
integrations: [
new Sentry.BrowserTracing({
routingInstrumentation: Sentry.reactRouterV6Instrumentation(useNavigate),
}),
],
});
// Capture failed mutations with context
mutation.onError((err, variables) => {
Sentry.captureException(err, {
extra: { eventId: variables.id, action: 'updateEvent' }
});
});| Metric | Alert Threshold |
|---|---|
| API error rate | > 1% |
| Event fetch P95 latency | > 500ms |
| Client JS error rate | > 0.5% of sessions |
| Offline sync failure rate | > 5% |
| View switch duration P90 | > 200ms |
Document Version: 1.0 · Last Updated: March 2026