VibeWeek
Home/Grow/Calendar Integrations: Google, Outlook, Apple — Chat Prompts

Calendar Integrations: Google, Outlook, Apple — Chat Prompts

⬅️ Back to 6. Grow

If your product creates or manages anything time-bound — meetings, appointments, deadlines, scheduled reminders, deliverables, sprints, classes, content drops, anything customers want to "have on their calendar" — eventually a customer will ask "can I sync this to my calendar?" The naive shape: ICS download links. Better: live Google + Outlook calendar OAuth integration with two-way sync. Best (and most painful): Google + Microsoft + Apple calendars, two-way sync, recurring-event rules, timezone correctness, conflict detection, and graceful degradation when a token expires. Get this wrong and customers double-book, miss meetings, or worse — you delete events on their calendar by accident and end up in a Reddit thread.

This is among the highest-leverage product features for any B2B SaaS that touches scheduling: customer adoption goes up, reminder fatigue goes down, and the product becomes "where time lives" for the user, which is sticky. It's also one of the most error-prone integrations because every calendar provider has different rules, different timezone behavior, different recurring-event semantics, and different rate limits.

This chat walks through implementing Google Calendar + Microsoft 365/Outlook + Apple iCloud calendar integrations: OAuth flows, event creation/update/delete, recurring events (RRULE), timezone handling, two-way sync, push notifications (webhooks), token refresh, and the operational realities of running this in production.

What you're building

  • Google Calendar API integration (OAuth + Calendar API)
  • Microsoft Graph API integration (OAuth + Outlook Calendar API)
  • Apple iCloud calendar (CalDAV — different protocol entirely)
  • Two-way sync: your events appear on user calendars; user changes flow back
  • Recurring events with proper RRULE handling
  • Timezone correctness end-to-end
  • Webhook subscriptions for change notifications (push, not poll)
  • Token refresh + graceful re-auth flow
  • ICS file download as fallback (universal compatibility)
  • Conflict detection (busy/free queries)

1. Decide the scope BEFORE writing code

Help me decide what shape of calendar integration I actually need.

Product context:
- I'm building [my product]
- Time-bound things in my product: [meetings / classes / deliverables / reminders / appointments]
- Volume: [N events created per user per week]
- Who creates events: [I do (push to user calendar), user does (read user calendar), or both]

Six shape decisions to make:

1. ONE-WAY OUTBOUND (your → user calendar)
   - You create events on user's calendar; user edits them in calendar
   - Simpler; covers 60% of needs
   - Examples: Calendly, course-platform meetings, deliverable reminders

2. ONE-WAY INBOUND (user → your)
   - You read user's busy/free for scheduling
   - Examples: Calendly availability, conflict detection
   - Often paired with #1

3. TWO-WAY SYNC
   - Changes in your product flow to user calendar
   - Changes user makes in their calendar flow back to your product
   - Hardest; needed when calendar IS the system of record (e.g., Calendly meeting reschedules)

4. ICS-ONLY (no OAuth)
   - User downloads .ics file; manually subscribes to a feed
   - No write access; no two-way; no real-time
   - Simplest; works with EVERY calendar (Google, Outlook, Apple, Linux, etc.)
   - Often the right v0 for indie products

5. WEBHOOK / WATCH (push notifications)
   - Cal provider notifies you when user calendar changes
   - Required for two-way sync at scale (avoid polling)

6. CALDAV (Apple-style protocol)
   - Old-school protocol; required for iCloud
   - Painful; consider supporting via ICS subscription instead

Decision rule:
- v0: ICS-only download links. Ships in 2 days. Covers 80% of single-event sharing.
- v1: Google Calendar OAuth + outbound write. 80% of B2B users, 1-2 weeks.
- v2: + Microsoft 365 OAuth. Adds enterprise users, 1-2 weeks.
- v3: Two-way sync + webhooks. 4-8 weeks of careful work.
- v4: Apple iCloud via CalDAV. Months of pain. Often skipped — recommend ICS subscription instead.

For most B2B SaaS in 2026, target v2 (Google + Outlook OAuth, outbound) with v0 (ICS) as the universal fallback. Add v3 only if calendar IS the system of record.

Output: a decision on which version you're shipping, with explicit "not yet" boundary.

Output: a scoped plan that prevents v3-on-v0 expectations.

2. Build v0: ICS file download (universal fallback)

Implement v0: per-event ICS download links.

Why this is the right v0:
- Works on EVERY calendar app (Google, Outlook, Apple, Linux, Thunderbird, anywhere)
- No OAuth; no token refresh; no API quotas
- One-time event creation; user manages from their calendar after
- Universal "fallback" once you ship richer integrations

ICS spec: RFC 5545 (iCalendar). Modern; well-supported.

Functions to implement:

generateIcsFile(event: {
  uid: string,           // unique identifier; persists across edits
  summary: string,       // event title
  description?: string,  // event body (multi-line allowed; escape per RFC 5545)
  location?: string,
  startsAt: Date,
  endsAt: Date,
  timezone: string,      // IANA tz: 'America/Los_Angeles'
  attendees?: { email: string, name?: string }[],
  organizer?: { email: string, name?: string },
  recurrence?: RRule,    // optional RFC 5545 RRULE
  url?: string,          // link back to your product
  status?: 'CONFIRMED' | 'TENTATIVE' | 'CANCELLED',
  created: Date,
  lastModified: Date,
  sequence: number,      // increment on every update; calendars use this for "this is newer"
}): string

Required fields:
- UID (unique-and-stable; once you set it, never change it)
- DTSTAMP (when this ICS was generated)
- DTSTART, DTEND (timezone-correct)
- SUMMARY
- SEQUENCE (start at 0; increment on each updated download)

Edge cases the implementation MUST handle:
- Escape special chars: \, ; ,  newlines, etc. (per RFC 5545)
- Timezones: include VTIMEZONE blocks for DST-safe iCal use, OR use UTC + Z suffix
- Multi-line DESCRIPTION: use \n for line breaks; fold lines at 75 chars per RFC
- Email addresses: validate before including
- Cancelled events: STATUS:CANCELLED + SEQUENCE bump signals "remove from calendar"

Recurring events (RRULE):
- 'every weekday at 10am for 6 weeks' → FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=30
- 'monthly on the 15th forever' → FREQ=MONTHLY;BYMONTHDAY=15
- Don't try to invent your own rule format; use RFC 5545 directly

Delivery options:
1. ICS file download endpoint: /events/[id].ics with Content-Type: text/calendar; method=PUBLISH
2. Subscribable feed (calendar URL the user pastes into their app):
   /api/users/[user_id]/calendar.ics — returns a multi-event feed; updates when user re-fetches
   - Important: serve with proper caching headers; calendars poll this URL hourly
   - SECURITY: feed URL contains a SECRET token (UUID); leak = entire calendar exposed; document warnings

Email-attach option:
- Attach the ICS file to email invitations
- Use Content-Type: text/calendar; method=REQUEST for email invites
- Most modern email clients (Gmail, Outlook, Apple Mail) parse and offer to add to calendar

Code:
1. Show me the ICS generation function with proper escaping
2. Show me a download route handler
3. Show me a subscribable feed handler with proper caching
4. Show me the email-attach pattern
5. Show me the security model for subscribable feed URLs (secret token, regenerate on demand)

Test against:
- Google Calendar (paste URL, import file, email invitation)
- Outlook desktop + Outlook.com web
- Apple Calendar (paste URL is finicky — test multiple times)
- Thunderbird (often catches edge-case bugs)

Output: a v0 that ships in 2-3 days and serves 80% of users.

3. Implement Google Calendar OAuth + write (v1)

Now implement Google Calendar integration.

Stack: [Next.js / your stack] + Google Calendar API v3 + OAuth 2.0

Step 1: Create Google Cloud project
- Enable Calendar API
- Create OAuth client (Web app type)
- Add authorized redirect: https://yourapp.com/api/integrations/google/callback
- Pick scopes: https://www.googleapis.com/auth/calendar.events (write events)
  - calendar.events: read/write events you create (recommended; less invasive)
  - calendar: read/write entire calendar (avoid unless needed)
  - calendar.readonly: just read
- Configure consent screen (verification required for production)
- Get client_id + client_secret

Step 2: Implement OAuth flow

Connect button → /api/integrations/google/connect
  - Redirect to https://accounts.google.com/o/oauth2/v2/auth with:
    - client_id
    - redirect_uri
    - response_type=code
    - scope=https://www.googleapis.com/auth/calendar.events
    - access_type=offline  (REQUIRED to get a refresh_token)
    - prompt=consent       (REQUIRED to force refresh_token on subsequent connects)
    - state=<csrf+user_id>

User consents → Google redirects to /api/integrations/google/callback?code=...
  - Validate state (CSRF check)
  - Exchange code for tokens at https://oauth2.googleapis.com/token
  - Receives: access_token (1hr), refresh_token (long-lived), expires_in, scope
  - Store: encrypted refresh_token + access_token + expires_at, link to user

Storage schema:

google_calendar_integrations (
  user_id            uuid pk
  google_user_id     text          -- 'sub' from Google
  google_user_email  text          -- for display
  access_token       text          -- ENCRYPTED at rest
  refresh_token      text          -- ENCRYPTED at rest
  token_expires_at   timestamptz
  scopes             text[]
  primary_calendar_id text         -- usually 'primary'
  watch_resource_id  text          -- for webhook subscriptions
  watch_expires_at   timestamptz
  connected_at       timestamptz
  last_used_at       timestamptz
  status             text          -- 'active', 'expired', 'revoked', 'error'
)

CRITICAL: encrypt tokens at rest. Use [Fernet / KMS / pgcrypto / your secrets]. Never store plaintext.

Step 3: Token refresh helper

async function getValidAccessToken(userId: string): Promise<string> {
  const integration = await db.googleIntegration.findById(userId)
  if (!integration) throw new NoIntegrationError()
  
  // If access token still valid (with 60s buffer), return it
  if (integration.token_expires_at > new Date(Date.now() + 60_000)) {
    return decrypt(integration.access_token)
  }
  
  // Otherwise refresh
  const response = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    body: new URLSearchParams({
      client_id, client_secret,
      refresh_token: decrypt(integration.refresh_token),
      grant_type: 'refresh_token'
    })
  })
  
  if (!response.ok) {
    // Common: refresh_token revoked by user (they removed app from Google account)
    await db.googleIntegration.update({ status: 'revoked' })
    throw new TokenRevokedError()  // trigger re-auth UI
  }
  
  const { access_token, expires_in } = await response.json()
  await db.googleIntegration.update({
    access_token: encrypt(access_token),
    token_expires_at: new Date(Date.now() + expires_in * 1000)
  })
  
  return access_token
}

Step 4: Create event

async function createEvent(userId: string, event: AppEvent): Promise<GoogleEvent> {
  const accessToken = await getValidAccessToken(userId)
  
  const body = {
    summary: event.title,
    description: event.description,
    location: event.location,
    start: {
      dateTime: event.startsAt.toISOString(),
      timeZone: event.timezone,  // IANA tz; Google requires this
    },
    end: {
      dateTime: event.endsAt.toISOString(),
      timeZone: event.timezone,
    },
    attendees: event.attendees?.map(a => ({ email: a.email, displayName: a.name })),
    reminders: { useDefault: true },
    extendedProperties: {
      private: { yourProductEventId: event.id }  // for round-trip identification
    },
    // For two-way sync: let user respond to invite
    guestsCanModify: false,
    guestsCanInviteOthers: false,
    transparency: 'opaque',  // 'opaque' = busy; 'transparent' = free
  }
  
  const response = await fetch(
    `https://www.googleapis.com/calendar/v3/calendars/${integration.primary_calendar_id}/events`,
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
      body: JSON.stringify(body)
    }
  )
  
  if (!response.ok) {
    if (response.status === 401) throw new TokenRevokedError()
    if (response.status === 403) throw new RateLimitedError()
    if (response.status === 410) throw new ResourceGoneError()  // calendar deleted
    throw new ApiError(await response.text())
  }
  
  return await response.json()
}

Update + delete are similar (PUT and DELETE).

Implement these and walk me through:
1. The OAuth flow handlers
2. The token refresh helper
3. The event create/update/delete functions
4. The error handling for each common failure mode
5. The "Connect Google Calendar" UI button + UX
6. The "Calendar Connected" state in your settings UI
7. Disconnect flow (revoke token; remove from DB)

Anti-patterns:
- Don't poll Google Calendar for changes (use webhooks; covered in step 5)
- Don't forget timezone — always send IANA tz with dateTime
- Don't sync events you didn't create (extendedProperties.private.yourProductEventId is your filter)
- Don't try to send Calendar API requests from the browser (CORS; expose secrets)
- Don't request unnecessary scopes (calendar vs calendar.events vs calendar.readonly)

Output: working Google outbound integration in ~1 week.

4. Implement Microsoft 365 / Outlook OAuth + write

Now add Microsoft 365 / Outlook calendar.

Microsoft API: Microsoft Graph API (covers Outlook calendar, mail, etc.)

Step 1: Register app in Azure AD
- Azure Portal → App Registrations → New
- Redirect URI (Web): https://yourapp.com/api/integrations/microsoft/callback
- API permissions:
  - Microsoft Graph → Calendars.ReadWrite (delegated; not application — for personal accounts use this)
  - offline_access (for refresh tokens)
  - User.Read (basic profile)
- Multi-tenant: enable for "Accounts in any organizational directory and personal Microsoft accounts"
  (covers business + consumer @outlook.com / @hotmail.com / @live.com)
- Get client_id + client_secret

Step 2: OAuth flow
- Authorize URL: https://login.microsoftonline.com/common/oauth2/v2.0/authorize
- Token URL: https://login.microsoftonline.com/common/oauth2/v2.0/token
- Common params: similar to Google with scope=offline_access+Calendars.ReadWrite
- Redirect back; exchange code for tokens
- access_token (1hr default; can be longer); refresh_token (90 days inactivity)

Storage schema:

microsoft_calendar_integrations (
  user_id            uuid pk
  microsoft_user_id  text          -- 'oid' from token
  microsoft_email    text
  access_token       text          -- encrypted
  refresh_token      text          -- encrypted
  token_expires_at   timestamptz
  primary_calendar_id text         -- often the user's default calendar
  scopes             text[]
  subscription_id    text          -- for webhook subscriptions
  subscription_expires_at timestamptz
  status             text
)

Step 3: Create event via Graph API

POST https://graph.microsoft.com/v1.0/me/events
Body: {
  subject: event.title,
  body: { contentType: 'HTML', content: event.description },
  start: { dateTime: event.startsAt.toISOString(), timeZone: event.timezone },
  end:   { dateTime: event.endsAt.toISOString(),   timeZone: event.timezone },
  location: { displayName: event.location },
  attendees: event.attendees.map(a => ({
    emailAddress: { address: a.email, name: a.name },
    type: 'required'
  })),
  showAs: 'busy',
  isReminderOn: true,
  reminderMinutesBeforeStart: 15,
  // For round-trip:
  singleValueExtendedProperties: [
    { id: 'String {id-of-your-namespace} Name yourProductEventId', value: event.id }
  ]
}

Differences from Google:
- TimeZone is the FIELD NAME 'timeZone' (not 'timeZone' lowercase)
- Body content uses 'contentType' (HTML or Text)
- Extended properties use a different API shape
- Recurrence uses Graph's recurrence object (not RRULE string directly; convert in your code)
- Time zones can be either IANA ('America/Los_Angeles') or Microsoft's own (Pacific Standard Time)
  - PREFER IANA in 2026; Graph supports both but IANA is portable
- 4xx error handling: Graph errors come back with { error: { code, message } } body

Common Graph errors:
- 401: token expired or revoked
- 403: user denied scope or admin policy blocks
- 429: throttled (Graph throttles aggressively)
- 503: backend unavailable; retry with backoff

Throttling note:
- Graph throttles per-app-per-tenant
- Honor Retry-After header
- Implement exponential backoff
- Spread requests across users; don't burst

Implement:
1. The Microsoft OAuth flow
2. The token refresh logic
3. The create/update/delete event functions
4. The recurrence conversion (RRULE → Graph recurrence object)
5. The Graph error handling
6. The throttling backoff
7. UI state for Microsoft connect/disconnect

Output: working Microsoft 365 + Outlook integration alongside Google.

5. Implement webhook subscriptions (push not poll)

For two-way sync, you need to know when the user changes events. Polling = slow + rate-limited. Webhooks = fast + cheap.

GOOGLE CALENDAR PUSH (Watch API):

POST https://www.googleapis.com/calendar/v3/calendars/primary/events/watch
Body: {
  id: <random-uuid>,         // your channel id
  type: 'web_hook',
  address: 'https://yourapp.com/api/integrations/google/webhook',
  token: '<verification-token-shared-secret>',
  expiration: 3600 * 24 * 7 * 1000  // 7 days max for events; in ms
}

Google sends: 
- Initial 'sync' notification
- Notifications on event changes
- Headers: X-Goog-Channel-Id, X-Goog-Resource-Id, X-Goog-Resource-State, X-Goog-Channel-Token

Webhook handler:
1. Validate X-Goog-Channel-Token matches the shared secret
2. Look up the user by channel id
3. If state='sync': initial; ignore
4. If state='exists': fetch events with sync token (incremental)
5. Reconcile changes with your DB
6. Renew the watch BEFORE expiration (cron job; check tomorrow's expiring watches)

Sync tokens (incremental sync):
- First fetch: pageToken=null; receive nextSyncToken
- Subsequent fetches: pageToken=nextSyncToken; receive only changes since last sync
- Save nextSyncToken per user; use on each notification
- If sync token is invalidated (Google's idea of "too old"): full re-sync required

MICROSOFT GRAPH SUBSCRIPTIONS:

POST https://graph.microsoft.com/v1.0/subscriptions
Body: {
  changeType: 'created,updated,deleted',
  notificationUrl: 'https://yourapp.com/api/integrations/microsoft/webhook',
  resource: '/me/events',
  expirationDateTime: ISO date (max 4230 min = ~3 days),
  clientState: '<verification-secret>'
}

Microsoft sends:
- Validation handshake first (POST with validationToken query param; respond 200 + plaintext token)
- Notifications: { value: [{ subscriptionId, changeType, resource, ... }] }
- Resource is just a pointer; FETCH the actual event to see changes

Webhook handler:
1. Handle validation handshake (respond 200 with validationToken text)
2. Validate clientState
3. For each notification: GET the resource to see current state
4. Reconcile with your DB
5. Renew subscription before expiration (~3 days)

Subscription renewal:
- Both providers expire watches/subscriptions
- Schedule a cron job (every hour or so) to renew watches expiring in next 24 hours
- If renewal fails: trigger re-auth UI (user removed app, etc.)

Webhook delivery edge cases:
- Notifications may be delivered MULTIPLE times (idempotency required)
- Notifications may be delivered OUT OF ORDER (use timestamps from event itself, not arrival time)
- Some changes may be MISSED if your endpoint is down (always do incremental sync via sync token after restart)
- Bulk changes (e.g. user deletes 100 events) may exceed batch size; handle pagination

Implement:
1. The Watch/Subscription creation when integration is set up
2. The webhook handler endpoints
3. The validation handshake (Microsoft)
4. The incremental sync logic with sync tokens
5. The renewal cron
6. The reconciliation logic (how do you merge user changes with your data?)

Reconciliation rules:
- If user updated an event you created: their changes win for fields user owns (title, time, location); your fields (extended props, custom data) are preserved
- If user deleted an event you created: mark deleted in your DB; don't recreate
- If user moved an event to a different calendar: detect via resource path; update accordingly
- Conflicts: log + alert; pick the latest mutation by timestamp

Output: real-time two-way sync without polling.

6. Handle recurring events (the hard part)

Recurring events are where calendar integrations break.

RFC 5545 RRULE basics:
- FREQ=DAILY|WEEKLY|MONTHLY|YEARLY
- INTERVAL=N (every N units)
- COUNT=N (occur N times) OR UNTIL=date (occur until date)
- BYDAY (e.g. MO,WE,FR)
- BYMONTHDAY (e.g. 15)
- BYMONTH (e.g. 1,7 = Jan + Jul)
- BYSETPOS (positional: -1 = last; e.g. last Friday of month)

Examples:
- 'every Monday at 9am for 10 weeks':
  RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10
- 'last Thursday of every month':
  RRULE:FREQ=MONTHLY;BYDAY=TH;BYSETPOS=-1
- 'every 3rd day forever':
  RRULE:FREQ=DAILY;INTERVAL=3
- 'every weekday for 6 months':
  RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20260930T000000Z

Recurrence + exceptions:
- Master event has the RRULE
- Specific instances can be EDITED individually (move time, change title)
- Specific instances can be DELETED (without affecting others)
- Both modes are tracked via "recurring instance overrides"

Google Calendar:
- Master event has 'recurrence' = [RRULE]
- Modified instances are CHILD events with 'recurringEventId' = master id, 'originalStartTime' = the slot
- Cancelled instances also get 'status': 'cancelled' on the child event

Microsoft Graph:
- Master event has 'recurrence' object (NOT raw RRULE; an object you must convert)
- Pattern: { type, interval, daysOfWeek, ... }
- Range: { type, startDate, endDate / numberOfOccurrences }
- Modified/cancelled instances accessible via /events/{id}/instances?startDateTime=&endDateTime=

In your data model:

events (
  id,
  starts_at,
  ends_at,
  timezone,
  rrule           text,             -- RFC 5545 string
  recurring_event_id text references events(id),
  is_recurring_master bool,
  is_modified_instance bool,
  original_start_at timestamptz     -- if modified instance
)

Updating a recurring event:
- "Update this instance only": creates a child event with override
- "Update this and following": split the master; new master from this date forward
- "Update all": mutate the master (and reset all instances)

The UI matters: don't let users get into a state where they update only one instance but expected the master.

Common bugs:
- Sending recurrence with 'COUNT' AND 'UNTIL' together (illegal; pick one)
- Sending RRULE with timezone-naive UNTIL (must be UTC + 'Z' suffix)
- Forgetting to handle DST transitions (start time '10am every Monday' shifts UTC offset)
- Missing exception handling — modified instances appear out of order

Implement:
1. RRULE → JS object parser (use rrule.js library)
2. RRULE expansion (next N occurrences for display)
3. RRULE → Microsoft Graph recurrence converter
4. The "this/this+future/all" UI for editing recurring events
5. Conflict resolution when user changes recurrence in one calendar but not the other
6. DST-correct expansion using IANA timezones

Anti-patterns:
- Don't store occurrences expanded in the DB (use the rule + on-demand expansion)
- Don't trust client time for occurrence calculation (do it server-side with IANA tz)
- Don't try to manage recurrence yourself if you don't need to (defer to provider)

Output: recurring events that don't drift over months.

7. Handle timezones correctly (everywhere)

Timezone bugs are the #1 source of "the calendar is broken" reports. Get this right.

Rules:
1. Always store timestamps in UTC (timestamptz in Postgres, ISO 8601 with Z in code).
2. Always store the user's intended timezone (IANA: 'America/Los_Angeles', not 'PST').
3. Always render timestamps in the user's tz at display time (not stored).
4. For events, the timezone IS part of the event (an event "Mon 10am LA time" should remain so even if the user travels).
5. Don't use abbreviations (PST/EST) — they're ambiguous (PST is also Philippines Standard Time). IANA only.

Common timezone bugs:
- Storing local time without tz: 'starts_at = 2026-05-01 10:00:00' is meaningless
- Using the SERVER's tz to format: dates show wrong to non-server-tz users
- Using JS Date.toLocaleString without explicitly passing timezone: silently uses browser tz
- Recurring events that don't observe DST: 10am Monday in March shifts to 11am UTC; 10am Monday in April is 10am UTC
- Naive 'UNTIL' in RRULE: must be UTC; sending local time breaks providers

Code patterns:

// Storing
const startsAtUtc = utcFromUserLocal(userInput, userTimezone);
db.event.create({ starts_at: startsAtUtc, timezone: userTimezone });

// Rendering for display
const displayTime = formatInTimezone(event.starts_at, viewer.timezone, 'h:mm a zzz');

// Sending to Google API
const body = {
  start: {
    dateTime: event.starts_at.toISOString(),
    timeZone: event.timezone  // 'America/Los_Angeles'
  }
};

// Sending to Microsoft Graph (same shape; use 'timeZone' field)
// IANA preferred in 2026; Microsoft's legacy names also accepted

Libraries:
- date-fns-tz (lightweight, modern)
- Luxon (heavier but very correct)
- Day.js + timezone plugin (smallest)
- DON'T use Moment (deprecated; large; legacy)

Test scenarios:
- User in LA creates an event for "Monday 10am"; viewer in NY sees "1pm"
- User on Asia/Tokyo creates a recurring weekly event; verify DST-free behavior
- User crosses date line (Honolulu user creates event at 11pm; UTC is the next day)
- Spring-forward DST: an event at "2:30am LA" doesn't exist on the spring-forward day
- Fall-back DST: an event at "1:30am LA" exists twice; pick canonical interpretation

Implement:
1. The user-tz selection in the UI (default to browser-detected)
2. The store-as-UTC + tz-string discipline
3. The display formatter
4. The provider-specific tz format
5. The DST handling for recurring events
6. End-to-end test with multiple tz users

Anti-patterns:
- Storing 'America/Los_Angeles 2026-05-01 10:00' as a single string: ambiguous on DST boundary
- 'UTC offset' fields ('+0700'): non-portable; offsets aren't tz
- Letting the database default tz handle it: server tz drift is silent and catastrophic
- 'Local time, no tz' for events: only valid for all-day events (which are date-only)

Output: timezones that don't betray you.

8. Build the UI: connect, disconnect, sync status

The user-facing UX matters as much as the backend.

UI surfaces:

1. Connection page (/settings/integrations/calendar):
   - Cards for Google Calendar, Outlook 365, "Subscribe via URL" (ICS)
   - Status: not connected / connecting / connected (with connected email shown) / error
   - "Connect" button → redirect to OAuth
   - "Disconnect" button → revoke token + remove DB row
   - For connected: show last-sync time, # of events synced, error count

2. Per-event sync indicator:
   - Small icon next to events showing "synced to Google Cal" / "syncing" / "sync failed"
   - Click → expand to show which calendars it's on
   - Useful for debugging when something goes wrong

3. Re-auth prompt:
   - If token revoked / expired beyond refresh: persistent banner
   - "Your Google Calendar connection has expired. [Reconnect]"
   - Don't silently fail; users get confused

4. Permission explanation modal:
   - Before redirecting to provider OAuth, show what scope you're requesting + why
   - "We'll request 'create events on your calendar' so [Product] can add meetings to your calendar."
   - Builds trust; reduces denial rate

5. Calendar selection UI (advanced):
   - User has multiple calendars (Work, Personal, Family); let them pick which
   - List calendars from API; persist choice
   - Default to primary

6. Conflict alert (optional):
   - On event creation, query free-busy: GET /freebusy?start=...&end=... for user's calendars
   - If conflict: warn before creating
   - Don't auto-block; user might not care

Server actions / endpoints:
- POST /api/integrations/google/connect → starts OAuth
- GET /api/integrations/google/callback → handles redirect
- POST /api/integrations/google/disconnect → revokes + removes
- POST /api/integrations/google/test-sync → manual sync trigger for debugging
- GET /api/integrations/calendars → list calendars for selection

Mobile considerations:
- OAuth flows in mobile webview: use ASWebAuthenticationSession (iOS) or Custom Tabs (Android)
- Don't use embedded webview (some providers block it as anti-abuse)

Implement these UIs + the supporting endpoints. Show me:
1. The /settings/integrations/calendar page layout
2. The connect/disconnect flow
3. The error/re-auth banner
4. The per-event indicator component
5. The mobile OAuth-with-deep-link pattern (if relevant)

Output: a UX that makes integration feel solid.

9. Handle the operational realities

Walk me through the edge cases I'll hit:

1. User revokes app from their Google account
   - Refresh fails 401
   - Mark integration revoked; banner UI; don't try anymore until re-auth
   - Cron purges fully-revoked integrations after 90 days

2. Token expiry while a job is mid-flight
   - Serialize token-refresh per user (mutex/lock); avoid double-refresh
   - Retry with new token; if refresh itself fails, raise

3. Rate limited (429)
   - Honor Retry-After header
   - Exponential backoff with jitter
   - Per-user request budget (don't hammer one user's tokens)
   - Aggregate across users into queue

4. User changes timezone
   - For displayed times: re-render in new tz
   - For SOURCE-OF-TRUTH events: store the tz the event was originally created in; don't auto-shift

5. User deletes an event you synced (via their Calendar app)
   - Webhook fires; you receive notification
   - Mark in your DB as 'externally_deleted' (don't auto-recreate)
   - Optional: if event was critical (e.g., a billed appointment), surface in your UI

6. User changes event title/time in their calendar
   - Webhook fires; you fetch the updated event
   - Reconcile with your data: what's the source of truth?
   - Common rule: title/description/location → user wins; structured data (attendees, custom fields) → you win

7. User moves event to a different calendar
   - Detected by webhook on different resource
   - Update DB to track the new calendar location

8. Sync token invalidated (Google's prerogative; happens for old tokens)
   - Full re-sync triggered
   - Mark all your synced events as "unknown state"
   - Re-fetch with no sync token; reconcile

9. Apple iCloud user (CalDAV)
   - IMPORTANT: Apple does NOT have a public OAuth + REST API
   - Options:
     a. Tell user to use ICS subscribe URL (recommended; ships in v0)
     b. CalDAV protocol (XML, basic auth with app-specific password)
     c. Suggest user uses Google or Outlook calendar instead
   - Don't try to build CalDAV unless you have a strong reason; it's painful

10. Bulk operation conflict (e.g. user creates 100 events at once)
    - Batch your API calls
    - Honor rate limits aggressively
    - Show progress to user
    - Make operation idempotent (resumable on failure)

11. Calendar event becomes orphaned (your record deleted; provider's still exists)
    - Cron: periodic reconciliation finds orphans
    - Don't auto-delete (could be user's intentional)
    - Show in admin UI for cleanup

12. Cross-DST event
    - Recurring events MUST be expanded with IANA tz, not naive arithmetic
    - Each instance's UTC time may differ across DST boundaries
    - Test with March/November transitions

For each, walk me through the code change + UX impact.

Output: integration that survives 6 months of real customer behavior.

10. Recap

What you've shipped:

  • v0: ICS file download + subscribable feed (universal fallback)
  • v1: Google Calendar OAuth + outbound write
  • v2: Microsoft 365 / Outlook + outbound write
  • v3: Two-way sync via webhooks + sync tokens
  • Recurring events with proper RRULE handling
  • Timezone correctness (IANA, store-UTC, render-local)
  • Token refresh with graceful re-auth
  • Connection UI + per-event sync indicators
  • Conflict detection (free/busy queries)
  • Operational handling for revoked/expired/rate-limited

What you're explicitly NOT shipping in v1-3:

  • Apple iCloud via CalDAV (use ICS subscribe; defer until critical)
  • Calendar resource booking (rooms, equipment) — different API surface
  • Calendar sharing (sharing your product's calendar with team) — separate feature
  • Cross-org calendars (delegated access; admin-managed) — enterprise feature
  • Third-party calendar providers (Fastmail, Zoho, etc.) — covered by ICS for most cases

Ship v0+v1+v2 in 3-4 weeks. Add v3 only if calendar IS the system of record for your product. Apple/CalDAV: defer indefinitely; ICS subscribe handles 95%.

The biggest mistake teams make: building two-way sync from day one when one-way would have shipped in 1/4 the time and covered 80% of the use case.

The second mistake: skipping ICS as fallback. ICS works for 100% of users; OAuth integrations work for ~85%. The fallback is your "long tail" for older customers, weird mail clients, and Apple iCloud users.

The third mistake: getting timezones wrong. Always IANA. Always store UTC + tz string. Always render in viewer tz. Never abbreviations (PST/EST/GMT).

See Also