OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking commits / 4170af4

Add Google Calendar integration spec (client priority feature)
Coaches connect Google Calendar, busy times automatically block booking slots. Privacy-preserving: only reads free/busy blocks, never event details. Spec covers: - OAuth2 flow (calendar.freebusy + calendarlist.readonly scopes) - FreeBusy API integration with busy block merging - Updated slot calculation (working hours - bookings - google events) - Calendar selection UI for coaches - 5-minute caching layer - Optional webhook support for real-time updates - Graceful degradation when Google API unavailable - Token security (encrypted refresh_token, short-lived access_token) - Edge cases (disconnect, API down, all-day events, overlaps) - 7-task build plan (Phase 2.5) - Zero new npm dependencies (REST API only) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4170af44d10cf7c3be5530f23a5395b853a801c7
by plankatron <fleeky@gmail.com> · 2026-03-22T19:16:14.000Z

Changed files (1) +671 -0

docs/design/v1-google-calendar.md +671 -0
……@@ -0,0 +1,671 @@
1# hyper-booking v1 — Google Calendar Integration Spec
2
3Client priority feature. Coach's availability is driven by their
4Google Calendar — busy times automatically block booking slots.
5
6---
7
8## Overview
9
10```
11Coach connects Google Calendar
12 → hyper-booking reads free/busy blocks
13 → Slot calculation = working hours MINUS Google Calendar events
14 → Client only sees truly available times
15```
16
17Instead of manually setting "I'm busy Tuesday 2-3pm", the coach's
18Google Calendar events automatically block those slots.
19
20---
21
22## 1. How It Works
23
24### Current Slot Calculation (Phase 1)
25
26```
27Available = weekly_schedule + date_overrides - existing_bookings
28```
29
30### With Google Calendar
31
32```
33Available = weekly_schedule + date_overrides - existing_bookings - google_busy_blocks
34```
35
36The weekly schedule stays — it defines the coach's **working hours**.
37Google Calendar subtracts from those hours when the coach has events.
38
39### Example
40
41```
42Coach's weekly schedule: Monday 9am-5pm
43Google Calendar events Monday:
44 - 10:00-11:00 "Team standup"
45 - 1:00-2:30 "Dentist appointment"
46
47Available slots for Monday (60min sessions, 15min buffer):
48 9:00-10:00 ✓ available
49 10:00-11:00 ✗ blocked (Team standup)
50 11:15-12:15 ✓ available
51 12:30-1:30 ✗ partially blocked (Dentist at 1:00)
52 2:45-3:45 ✓ available
53 4:00-5:00 ✓ available
54```
55
56---
57
58## 2. OAuth2 Flow
59
60### Scopes (minimal, privacy-preserving)
61
62| Scope | Purpose |
63|-------|---------|
64| `calendar.freebusy` | Read busy time blocks (no event details — just start/end) |
65| `calendar.calendarlist.readonly` | List calendars so coach can pick which to monitor |
66
67These are **sensitive** scopes (not restricted). No event titles, descriptions,
68or attendee info is ever accessed.
69
70### Connection Flow
71
72```
731. Coach goes to Dashboard → Settings → Calendar
742. Clicks "Connect Google Calendar"
753. Server generates OAuth2 authorization URL
764. Coach redirected to Google consent screen:
77 "This app wants to:
78 - See the availability on Google calendars you have access to
79 - See the list of Google calendars you're subscribed to"
805. Coach clicks Allow
816. Google redirects to /api/gcal/callback?code=AUTH_CODE
827. Server exchanges code for access_token + refresh_token
838. Server fetches calendar list, shows to coach
849. Coach selects which calendars to monitor
8510. Done — slots now factor in Google Calendar
86```
87
88### Token Storage
89
90```javascript
91{
92 providerId: 'prov_xyz',
93 googleAuth: {
94 refreshToken: '<encrypted>', // long-lived, used to get fresh access tokens
95 accessToken: '<short-lived>', // expires in ~1 hour
96 expiresAt: timestamp,
97 scope: 'calendar.freebusy calendar.calendarlist.readonly',
98 connectedAt: timestamp
99 },
100 selectedCalendars: [
101 { id: 'primary', name: 'Main Calendar' },
102 { id: 'work@group.calendar.google.com', name: 'Work' }
103 ]
104}
105```
106
107**Storage:** `gcal-auth:{providerId}` → auth + calendar selection
108
109### Token Refresh
110
111```javascript
112async function getAccessToken (providerId) {
113 const entry = await db.get(`gcal-auth:${providerId}`)
114 if (!entry) return null
115
116 const auth = entry.value
117 // Refresh if expired or within 5 minutes of expiry
118 if (Date.now() > auth.googleAuth.expiresAt - 300000) {
119 const tokens = await refreshGoogleToken(auth.googleAuth.refreshToken)
120 auth.googleAuth.accessToken = tokens.access_token
121 auth.googleAuth.expiresAt = Date.now() + (tokens.expires_in * 1000)
122 await db.put(`gcal-auth:${providerId}`, auth)
123 }
124
125 return auth.googleAuth.accessToken
126}
127```
128
129---
130
131## 3. FreeBusy API
132
133The only Google API call needed at booking time.
134
135### Request
136
137```
138POST https://www.googleapis.com/calendar/v3/freeBusy
139Authorization: Bearer <access_token>
140
141{
142 "timeMin": "2026-03-26T00:00:00Z",
143 "timeMax": "2026-03-26T23:59:59Z",
144 "timeZone": "America/New_York",
145 "items": [
146 { "id": "primary" },
147 { "id": "work@group.calendar.google.com" }
148 ]
149}
150```
151
152### Response
153
154```json
155{
156 "calendars": {
157 "primary": {
158 "busy": [
159 { "start": "2026-03-26T10:00:00-04:00", "end": "2026-03-26T11:00:00-04:00" },
160 { "start": "2026-03-26T13:00:00-04:00", "end": "2026-03-26T14:30:00-04:00" }
161 ]
162 },
163 "work@group.calendar.google.com": {
164 "busy": [
165 { "start": "2026-03-26T15:00:00-04:00", "end": "2026-03-26T16:00:00-04:00" }
166 ]
167 }
168 }
169}
170```
171
172Only returns start/end time blocks. **No event titles, descriptions, or attendees.**
173
174### Merged Busy Blocks
175
176Merge all calendars into a single busy list:
177
178```javascript
179function mergeBusyBlocks (freeBusyResponse) {
180 const allBusy = []
181
182 for (const calData of Object.values(freeBusyResponse.calendars)) {
183 for (const block of calData.busy || []) {
184 allBusy.push({
185 start: new Date(block.start).getTime(),
186 end: new Date(block.end).getTime()
187 })
188 }
189 }
190
191 // Sort and merge overlapping blocks
192 allBusy.sort((a, b) => a.start - b.start)
193
194 const merged = []
195 for (const block of allBusy) {
196 const last = merged[merged.length - 1]
197 if (last && block.start <= last.end) {
198 last.end = Math.max(last.end, block.end)
199 } else {
200 merged.push({ ...block })
201 }
202 }
203
204 return merged
205}
206```
207
208---
209
210## 4. Updated Slot Calculation
211
212### Changes to `calculateSlots()`
213
214```javascript
215async function calculateSlots (db, providerId, date, duration) {
216 // ... existing code: get availability, windows, advance booking check ...
217
218 // NEW: Get Google Calendar busy blocks
219 let gcalBusy = []
220 const gcalEntry = await db.get(`gcal-auth:${providerId}`)
221 if (gcalEntry && gcalEntry.value.googleAuth) {
222 try {
223 gcalBusy = await fetchGoogleBusy(providerId, date, gcalEntry.value)
224 } catch (err) {
225 console.error('[GCal] FreeBusy failed:', err.message)
226 // Don't block slot calculation if Google API fails
227 }
228 }
229
230 // ... existing code: generate allSlots from windows ...
231
232 // Remove slots that conflict with existing bookings
233 const bookings = await getBookingsForDate(db, providerId, date)
234
235 const availableSlots = allSlots.filter(slot => {
236 // Check hyper-booking conflicts
237 const hasBookingConflict = bookings.some(b => {
238 if (b.status === 'cancelled') return false
239 const bStart = timeToMs(b.startTimeLocal) - bufferMs
240 const bEnd = timeToMs(b.endTimeLocal) + bufferMs
241 return slot.start < bEnd && slot.end > bStart
242 })
243 if (hasBookingConflict) return false
244
245 // NEW: Check Google Calendar conflicts
246 const dateMs = new Date(date + 'T00:00:00Z').getTime()
247 const slotStartAbsolute = dateMs + slot.start
248 const slotEndAbsolute = dateMs + slot.end
249
250 const hasGcalConflict = gcalBusy.some(busy =>
251 slotStartAbsolute < busy.end && slotEndAbsolute > busy.start
252 )
253 if (hasGcalConflict) return false
254
255 return true
256 })
257
258 return availableSlots
259}
260```
261
262### Fetch Google Busy Blocks
263
264```javascript
265async function fetchGoogleBusy (providerId, date, gcalConfig) {
266 const accessToken = await getAccessToken(providerId)
267 if (!accessToken) return []
268
269 const timeMin = date + 'T00:00:00Z'
270 const timeMax = date + 'T23:59:59Z'
271
272 const items = gcalConfig.selectedCalendars.map(c => ({ id: c.id }))
273
274 const res = await fetch('https://www.googleapis.com/calendar/v3/freeBusy', {
275 method: 'POST',
276 headers: {
277 Authorization: 'Bearer ' + accessToken,
278 'Content-Type': 'application/json'
279 },
280 body: JSON.stringify({
281 timeMin,
282 timeMax,
283 timeZone: gcalConfig.timezone || 'UTC',
284 items
285 })
286 })
287
288 if (!res.ok) {
289 const err = await res.json()
290 throw new Error(err.error?.message || 'FreeBusy query failed')
291 }
292
293 const data = await res.json()
294 return mergeBusyBlocks(data)
295}
296```
297
298### Caching
299
300Cache FreeBusy results to avoid hitting Google on every slot query:
301
302```javascript
303const GCAL_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
304
305async function fetchGoogleBusyCached (db, providerId, date, gcalConfig) {
306 const cacheKey = `gcal-cache:${providerId}:${date}`
307 const cached = await db.get(cacheKey)
308
309 if (cached && Date.now() - cached.value.fetchedAt < GCAL_CACHE_TTL) {
310 return cached.value.busy
311 }
312
313 const busy = await fetchGoogleBusy(providerId, date, gcalConfig)
314
315 await db.put(cacheKey, { busy, fetchedAt: Date.now() })
316
317 return busy
318}
319```
320
321---
322
323## 5. Calendar Selection UI
324
325After OAuth connection, coach picks which calendars to monitor.
326
327### Dashboard → Settings → Calendar
328
329```
330┌──────┬──────────────────────────────────────────────┐
331│ Nav │ Google Calendar │
332│ │ │
333│ │ Status: ● Connected │
334│ │ Account: alexis@gmail.com │
335│ │ [Disconnect] │
336│ │ │
337│ │ Monitor these calendars for conflicts: │
338│ │ ┌────────────────────────────────────────┐ │
339│ │ │ [✓] Main Calendar (primary) │ │
340│ │ │ [✓] Work │ │
341│ │ │ [ ] Holidays in United States │ │
342│ │ │ [ ] Birthdays │ │
343│ │ │ [✓] Personal │ │
344│ │ └────────────────────────────────────────┘ │
345│ │ [Save Selection] │
346│ │ │
347│ │ Events on selected calendars will │
348│ │ automatically block booking slots. │
349│ │ Only availability (busy/free) is read — │
350│ │ event details are never accessed. │
351│ │ │
352└──────┴──────────────────────────────────────────────┘
353```
354
355### Not Connected State
356
357```
358┌──────┬──────────────────────────────────────────────┐
359│ Nav │ Google Calendar │
360│ │ │
361│ │ Status: ○ Not connected │
362│ │ │
363│ │ Connect your Google Calendar to │
364│ │ automatically block times when you │
365│ │ have events scheduled. │
366│ │ │
367│ │ [Connect Google Calendar] │
368│ │ │
369│ │ We only read your availability │
370│ │ (busy/free times). We never see event │
371│ │ titles, descriptions, or attendees. │
372│ │ │
373└──────┴──────────────────────────────────────────────┘
374```
375
376---
377
378## 6. API Endpoints
379
380```
381# OAuth flow
382GET /api/gcal/connect → Redirect to Google OAuth consent
383GET /api/gcal/callback → OAuth callback, exchange code for tokens
384POST /api/gcal/disconnect → Revoke tokens, remove connection
385
386# Calendar management
387GET /api/gcal/calendars → List coach's Google calendars
388PUT /api/gcal/calendars → Update selected calendars
389
390# Status
391GET /api/gcal/status → Connection status + selected calendars
392```
393
394### Implementation
395
396```javascript
397// GET /api/gcal/connect
398async function handleConnect (req, res, server) {
399 const auth = await requireAuth(req, server, 'write')
400 const providerId = await getProviderIdForUser(server, auth.userId)
401
402 const params = new URLSearchParams({
403 client_id: process.env.GOOGLE_CLIENT_ID,
404 redirect_uri: process.env.GOOGLE_REDIRECT_URI,
405 response_type: 'code',
406 scope: [
407 'https://www.googleapis.com/auth/calendar.freebusy',
408 'https://www.googleapis.com/auth/calendar.calendarlist.readonly'
409 ].join(' '),
410 access_type: 'offline', // get refresh_token
411 prompt: 'consent', // always show consent (ensures refresh_token)
412 state: providerId // pass provider ID through OAuth flow
413 })
414
415 redirect(res, 'https://accounts.google.com/o/oauth2/v2/auth?' + params)
416}
417
418// GET /api/gcal/callback?code=AUTH_CODE&state=PROVIDER_ID
419async function handleCallback (req, res, server) {
420 const url = new URL('http://x' + req.url)
421 const code = url.searchParams.get('code')
422 const providerId = url.searchParams.get('state')
423
424 // Exchange code for tokens
425 const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
426 method: 'POST',
427 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
428 body: new URLSearchParams({
429 code,
430 client_id: process.env.GOOGLE_CLIENT_ID,
431 client_secret: process.env.GOOGLE_CLIENT_SECRET,
432 redirect_uri: process.env.GOOGLE_REDIRECT_URI,
433 grant_type: 'authorization_code'
434 })
435 })
436
437 const tokens = await tokenRes.json()
438
439 // Store tokens
440 await server.db.put(`gcal-auth:${providerId}`, {
441 googleAuth: {
442 refreshToken: tokens.refresh_token,
443 accessToken: tokens.access_token,
444 expiresAt: Date.now() + (tokens.expires_in * 1000),
445 scope: tokens.scope,
446 connectedAt: Date.now()
447 },
448 selectedCalendars: [{ id: 'primary', name: 'Main Calendar' }]
449 })
450
451 // Redirect to settings page
452 redirect(res, '/dashboard/settings?gcal=connected')
453}
454```
455
456---
457
458## 7. Push Notifications (Real-Time Updates)
459
460Optional — falls back to polling/cache if not available.
461
462### Requirements for Push
463
464- Public HTTPS endpoint (needs domain + TLS cert)
465- Domain verified in Google Search Console
466- Watch channels expire after ~7 days (need renewal cron)
467
468### Setup
469
470```javascript
471// Watch for changes on coach's calendar
472async function setupCalendarWatch (providerId) {
473 const accessToken = await getAccessToken(providerId)
474 const gcalConfig = await db.get(`gcal-auth:${providerId}`)
475
476 for (const cal of gcalConfig.value.selectedCalendars) {
477 await fetch(
478 `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(cal.id)}/events/watch`,
479 {
480 method: 'POST',
481 headers: {
482 Authorization: 'Bearer ' + accessToken,
483 'Content-Type': 'application/json'
484 },
485 body: JSON.stringify({
486 id: crypto.randomUUID(),
487 type: 'web_hook',
488 address: process.env.GCAL_WEBHOOK_URL + '/api/gcal/webhook',
489 token: providerId,
490 expiration: Date.now() + 6 * 24 * 60 * 60 * 1000 // 6 days
491 })
492 }
493 )
494 }
495}
496
497// POST /api/gcal/webhook (called by Google)
498async function handleWebhook (req, res) {
499 const providerId = req.headers['x-goog-channel-token']
500 const state = req.headers['x-goog-resource-state']
501
502 if (state === 'exists') {
503 // Calendar changed — invalidate cache
504 const gcalConfig = await db.get(`gcal-auth:${providerId}`)
505 if (gcalConfig) {
506 // Clear all cached busy blocks for this provider
507 for await (const entry of db.createReadStream({
508 gte: `gcal-cache:${providerId}:`,
509 lte: `gcal-cache:${providerId}:~`
510 })) {
511 await db.del(entry.key)
512 }
513 }
514 }
515
516 res.statusCode = 200
517 res.end()
518}
519```
520
521### Without Push (fallback)
522
523If no public HTTPS endpoint is available (e.g., running on LAN only):
524- Cache FreeBusy results for 5 minutes
525- Re-query on every slot lookup (cached)
526- Coach can click "Refresh Calendar" manually in dashboard
527
528This is fine for v1 — most coaches update their personal calendar infrequently.
529
530---
531
532## 8. Configuration
533
534### Environment Variables
535
536```bash
537# Google OAuth (required for calendar integration)
538GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
539GOOGLE_CLIENT_SECRET=GOCSPX-xxx
540GOOGLE_REDIRECT_URI=http://tabertron.local:4050/api/gcal/callback
541
542# Google Calendar Webhooks (optional, requires HTTPS)
543GCAL_WEBHOOK_URL=https://booking.yourdomain.com
544```
545
546### Google Cloud Console Setup
547
5481. Create project at console.cloud.google.com
5492. Enable "Google Calendar API"
5503. Create OAuth 2.0 credentials (Web application)
5514. Add authorized redirect URI: `http://tabertron.local:4050/api/gcal/callback`
5525. For production: complete app verification (sensitive scopes)
553
554### Development (before verification)
555
556- Limited to 100 test users (add coach's Google account as test user)
557- Tokens expire after 7 days in testing mode
558- Consent screen shows "This app isn't verified" warning
559
560---
561
562## 9. Privacy & Security
563
564### What We Access
565
566| Data | Accessed | Purpose |
567|------|----------|---------|
568| Free/busy time blocks | Yes | Block booking slots |
569| Calendar list (names, IDs) | Yes | Let coach pick which calendars |
570| Event titles | **No** | Never accessed |
571| Event descriptions | **No** | Never accessed |
572| Attendee info | **No** | Never accessed |
573| Event locations | **No** | Never accessed |
574
575### Token Security
576
577- `refresh_token` stored encrypted in Hyperbee
578- `access_token` short-lived (~1 hour), refreshed on demand
579- Coach can disconnect at any time (revokes all access)
580- No Google data stored beyond busy time blocks (cached 5 min)
581
582---
583
584## 10. Edge Cases
585
586### Coach Disconnects Google
587
588- Slot calculation falls back to working hours only (no Google blocking)
589- Existing bookings unaffected
590- Cached busy blocks deleted
591
592### Google API Down
593
594- FreeBusy query fails → catch error → show slots based on working hours only
595- Log warning, don't block booking
596- Toast: "Google Calendar temporarily unavailable, some busy times may not be reflected"
597
598### All-Day Events
599
600- Google FreeBusy reports all-day events as busy blocks
601- These block the entire working day
602- Coach should use "Free" show-as status for non-blocking all-day events
603
604### Multiple Calendars with Overlapping Events
605
606- Merge all busy blocks → deduplicate overlaps
607- A slot is blocked if ANY selected calendar has an event
608
609### Coach Changes Calendar Mid-Day
610
611- Cached results expire after 5 minutes
612- New booking lookups see the updated availability
613- With webhooks: cache invalidated immediately
614
615---
616
617## 11. Build Plan
618
619### Phase 2.5 (insert between Phase 2 and Phase 3)
620
621```
622Task 1: Google OAuth endpoints
623 - /api/gcal/connect, /api/gcal/callback, /api/gcal/disconnect
624 - Token exchange, refresh, storage
625 - Environment variable config
626
627Task 2: Calendar list + selection
628 - /api/gcal/calendars (list), PUT (save selection)
629 - Settings UI: calendar picker
630
631Task 3: FreeBusy integration
632 - fetchGoogleBusy() function
633 - Merge busy blocks from multiple calendars
634 - Cache layer (5-min TTL)
635
636Task 4: Slot calculation update
637 - Add Google busy blocks to calculateSlots()
638 - Test: slots blocked by Google events
639 - Test: graceful fallback when Google unavailable
640
641Task 5: Settings UI
642 - "Connect Google Calendar" button
643 - Calendar selection checkboxes
644 - Connection status display
645 - Disconnect button
646
647Task 6: Tests
648 - Mock Google API responses
649 - Test busy block merging
650 - Test slot calculation with Google blocks
651 - Test OAuth flow (redirect URLs, token exchange)
652 - Test cache invalidation
653 - Test graceful degradation
654
655Task 7: Webhook support (optional, needs HTTPS)
656 - Watch channel setup + renewal
657 - Cache invalidation on webhook
658 - Renewal cron (every 6 days)
659```
660
661### Dependencies
662
663- `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` from Google Cloud Console
664- For dev: add test user accounts to OAuth consent screen
665- For production: complete Google app verification
666
667### No New npm Dependencies
668
669- OAuth is just HTTP requests (bare-fetch or bare-http1)
670- No Google SDK needed — REST API only
671- Token storage in existing Hyperbee