From bfe374206c573299d19ba8f5d14520ee1c6b0194 Mon Sep 17 00:00:00 2001 From: jhodgkin Date: Fri, 10 Jul 2026 00:10:31 -0600 Subject: [PATCH] Add Google Calendar availability date picker (issue #3) - api/google-calendar.js: service account JWT auth + freebusy query using only Node.js built-in crypto (no new npm deps). In-memory cache: 5-min busy dates, 1-hr OAuth token. Gracefully returns empty busy list when GOOGLE_CALENDAR_ID / GOOGLE_SERVICE_ACCOUNT_JSON not configured. - api/server.js: GET /api/availability?year=YYYY&month=MM endpoint with 5-min Cache-Control; errors return { busy:[], configured:false } so the UI always works even if Calendar is unavailable. - src/index.html: replace plain in step 1 with a custom month-grid calendar (dpInit/dpRender/dpNav/dpSelectDate). Hidden #session-date input carries the value for the rest of the wizard. Past dates and busy dates are visually blocked; month nav pre-fetches. - tests/booking.spec.js: 4 new calendar UI tests + updated openAndPickDate helper to click a rendered day cell. - tests/api.spec.js: 2 new availability endpoint tests. Setup: set GOOGLE_CALENDAR_ID and GOOGLE_SERVICE_ACCOUNT_JSON in api/.env. See api/google-calendar.js header for step-by-step instructions. Co-Authored-By: Claude Sonnet 4.6 --- api/google-calendar.js | 127 +++++++++++++++++++++ api/server.js | 20 ++++ src/index.html | 249 +++++++++++++++++++++++++++++++++++++++-- tests/api.spec.js | 18 +++ tests/booking.spec.js | 60 +++++++++- 5 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 api/google-calendar.js diff --git a/api/google-calendar.js b/api/google-calendar.js new file mode 100644 index 0000000..3c1791b --- /dev/null +++ b/api/google-calendar.js @@ -0,0 +1,127 @@ +/** + * Google Calendar availability helper. + * + * Uses a service account JWT (no external npm deps — only Node.js built-in crypto). + * Env vars required: + * GOOGLE_CALENDAR_ID — e.g. "abc123@group.calendar.google.com" + * GOOGLE_SERVICE_ACCOUNT_JSON — full service account key JSON as a single-line string + * GOOGLE_TIMEZONE — optional, defaults to "America/Denver" + * + * Setup (one-time): + * 1. Enable Calendar API in Google Cloud Console. + * 2. Create a service account; download the JSON key. + * 3. Share the target calendar with the service account's client_email (View permission). + * 4. Set GOOGLE_CALENDAR_ID to the calendar's ID (found in Calendar Settings → Integrate). + * 5. Set GOOGLE_SERVICE_ACCOUNT_JSON to the contents of the JSON key file. + */ + +'use strict'; +const crypto = require('crypto'); + +// ── Simple in-memory cache ──────────────────────────────────────────────────── + +let _tokenCache = null; // { token, expiresAt } +const _busyCache = new Map(); // "YYYY-MM" → { dates: Set, fetchedAt } +const BUSY_TTL_MS = 5 * 60 * 1000; // 5 minutes + +// ── JWT / OAuth ─────────────────────────────────────────────────────────────── + +function b64url(buf) { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +async function getAccessToken() { + if (_tokenCache && _tokenCache.expiresAt > Date.now() + 60_000) { + return _tokenCache.token; + } + + const sa = JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_JSON); + const now = Math.floor(Date.now() / 1000); + + const header = b64url(Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))); + const payload = b64url(Buffer.from(JSON.stringify({ + iss: sa.client_email, + scope: 'https://www.googleapis.com/auth/calendar.readonly', + aud: 'https://oauth2.googleapis.com/token', + iat: now, + exp: now + 3600, + }))); + + const sign = crypto.createSign('RSA-SHA256'); + sign.update(`${header}.${payload}`); + const sig = b64url(sign.sign(sa.private_key)); + + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${header}.${payload}.${sig}`, + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Google OAuth failed: ${res.status} ${err}`); + } + + const { access_token, expires_in } = await res.json(); + _tokenCache = { token: access_token, expiresAt: Date.now() + expires_in * 1000 }; + return access_token; +} + +// ── Busy-date fetcher ───────────────────────────────────────────────────────── + +/** + * Returns busy dates for the given month. + * @param {number} year — e.g. 2026 + * @param {number} month — 1-based, e.g. 7 for July + * @returns {{ busy: string[], configured: boolean }} + */ +async function getBusyDates(year, month) { + const calId = process.env.GOOGLE_CALENDAR_ID; + const saJson = process.env.GOOGLE_SERVICE_ACCOUNT_JSON; + + if (!calId || !saJson) { + return { busy: [], configured: false }; + } + + const cacheKey = `${year}-${String(month).padStart(2, '0')}`; + const cached = _busyCache.get(cacheKey); + if (cached && Date.now() - cached.fetchedAt < BUSY_TTL_MS) { + return { busy: [...cached.dates], configured: true }; + } + + const tz = process.env.GOOGLE_TIMEZONE || 'America/Denver'; + // Full month window: first moment of the month → first moment of next month + const timeMin = new Date(year, month - 1, 1).toISOString(); + const timeMax = new Date(year, month, 1).toISOString(); + + const token = await getAccessToken(); + const res = await fetch('https://www.googleapis.com/calendar/v3/freeBusy', { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ timeMin, timeMax, timeZone: tz, items: [{ id: calId }] }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Calendar freeBusy failed: ${res.status} ${err}`); + } + + const data = await res.json(); + const busyRanges = data.calendars?.[calId]?.busy ?? []; + + // Expand each busy range into individual YYYY-MM-DD strings + const busyDates = new Set(); + for (const { start, end } of busyRanges) { + let d = new Date(start); + const e = new Date(end); + while (d < e) { + busyDates.add(d.toISOString().slice(0, 10)); + d.setUTCDate(d.getUTCDate() + 1); + } + } + + _busyCache.set(cacheKey, { dates: busyDates, fetchedAt: Date.now() }); + return { busy: [...busyDates], configured: true }; +} + +module.exports = { getBusyDates }; diff --git a/api/server.js b/api/server.js index 30d418d..4a21c54 100644 --- a/api/server.js +++ b/api/server.js @@ -4,6 +4,7 @@ const cors = require('cors'); const fs = require('fs'); const path = require('path'); const db = require('./db'); +const { getBusyDates } = require('./google-calendar'); const app = express(); const PORT = process.env.PORT || 3001; @@ -48,6 +49,25 @@ app.get('/api/health', (req, res) => { res.json({ ok: true }); }); +// Availability — returns busy dates for a given month from Google Calendar. +// Gracefully returns an empty busy list when Calendar is not configured. +app.get('/api/availability', async (req, res) => { + const year = parseInt(req.query.year, 10); + const month = parseInt(req.query.month, 10); + if (!year || !month || month < 1 || month > 12) { + return res.status(400).json({ error: 'year and month (1-12) are required' }); + } + try { + const result = await getBusyDates(year, month); + res.setHeader('Cache-Control', 'public, max-age=300'); // 5-min CDN cache + res.json(result); + } catch (err) { + console.error('[availability]', err.message); + // Don't expose internal error; return empty so the UI can still function + res.json({ busy: [], configured: false, error: 'calendar_unavailable' }); + } +}); + // Serve the contract template PDF to the frontend PDF.js viewer app.get('/api/contracts/template', (req, res) => { const contractPath = path.join(CONTRACTS_DIR, 'model-release.pdf'); diff --git a/src/index.html b/src/index.html index 7c22a43..abe0296 100644 --- a/src/index.html +++ b/src/index.html @@ -591,6 +591,73 @@ } } + /* ── Date Picker Calendar ───────────────────────────── */ + .date-picker { user-select: none; } + + .dp-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 0.75rem; + } + .dp-month-label { + font-family: var(--font-display); font-size: 1.15rem; font-weight: 400; + color: var(--color-text); + } + .dp-nav-btn { + background: none; border: 1px solid var(--color-accent); border-radius: 3px; + width: 30px; height: 30px; cursor: pointer; font-size: 1rem; + color: var(--color-text-light); display: flex; align-items: center; justify-content: center; + transition: border-color 0.15s, color 0.15s; + } + .dp-nav-btn:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); } + .dp-nav-btn:disabled { opacity: 0.35; cursor: default; } + + .dp-grid { + display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; + } + .dp-dow { + text-align: center; font-size: 0.65rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.08em; + color: var(--color-text-light); padding: 0.3rem 0 0.5rem; + } + .dp-day { + aspect-ratio: 1; display: flex; align-items: center; justify-content: center; + font-size: 0.85rem; border-radius: 50%; cursor: pointer; + border: 1.5px solid transparent; transition: background 0.15s, border-color 0.15s, color 0.15s; + color: var(--color-text); + } + .dp-day:hover:not(.dp-day--disabled):not(.dp-day--selected) { + border-color: var(--color-primary); color: var(--color-primary); + } + .dp-day--other { color: var(--color-accent); cursor: default; } + .dp-day--past { color: var(--color-accent); cursor: not-allowed; } + .dp-day--busy { + color: var(--color-accent); cursor: not-allowed; + text-decoration: line-through; text-decoration-color: var(--color-accent); + } + .dp-day--disabled { cursor: not-allowed; } + .dp-day--today { border-color: var(--color-accent); } + .dp-day--selected { + background: var(--color-primary); color: var(--color-white) !important; + border-color: var(--color-primary); font-weight: 600; + } + .dp-legend { + display: flex; gap: 1rem; margin-top: 0.75rem; font-size: 0.7rem; + color: var(--color-text-light); flex-wrap: wrap; + } + .dp-legend-dot { + width: 8px; height: 8px; border-radius: 50%; display: inline-block; + vertical-align: middle; margin-right: 3px; + } + .dp-loading { + text-align: center; padding: 1.5rem 0; font-size: 0.82rem; + color: var(--color-text-light); font-style: italic; + } + .dp-status { + font-size: 0.75rem; color: var(--color-text-light); margin-top: 0.5rem; + min-height: 1.1em; + } + .dp-status.dp-status--ok { color: var(--color-primary); } + /* ── Booking Modal ─────────────────────────────────── */ .booking-overlay { @@ -1417,11 +1484,20 @@

Choose a Date

Select an available date for your session.

-
- - +
+
+ + + +
+
+
+ Selected + Unavailable +
+
- +
@@ -1815,7 +1891,167 @@ return _origApply(config); } - // ── Booking Flow ────────────────────────────────────── + // ── Date Picker ─────────────────────────────────────── + + const _dpState = { + year: 0, month: 0, // currently displayed month + selectedDate: '', // YYYY-MM-DD + busyCache: {}, // "YYYY-MM" → Set of YYYY-MM-DD strings + loadingMonths: new Set(), // months currently being fetched + calendarConfigured: null, // null = unknown, true/false after first fetch + }; + + // Minimum selectable date: tomorrow (no same-day bookings) + function dpMinDate() { + const d = new Date(); + d.setDate(d.getDate() + 1); + return d.toISOString().slice(0, 10); + } + + function dpIsBusy(dateStr) { + const key = dateStr.slice(0, 7); + const set = _dpState.busyCache[key]; + return set ? set.has(dateStr) : false; + } + + function dpIsDisabled(dateStr) { + return dateStr < dpMinDate() || dpIsBusy(dateStr); + } + + async function dpFetchMonth(year, month) { + const key = `${year}-${String(month).padStart(2, '0')}`; + if (_dpState.busyCache[key] !== undefined || _dpState.loadingMonths.has(key)) return; + _dpState.loadingMonths.add(key); + try { + const res = await fetch(`/api/availability?year=${year}&month=${month}`); + if (!res.ok) throw new Error(res.status); + const { busy, configured } = await res.json(); + _dpState.busyCache[key] = new Set(busy); + if (_dpState.calendarConfigured === null) { + _dpState.calendarConfigured = configured; + } + } catch (e) { + // On error, treat month as having no busy dates (fail-open) + _dpState.busyCache[key] = new Set(); + } finally { + _dpState.loadingMonths.delete(key); + // Re-render if this is the currently displayed month + const cur = `${_dpState.year}-${String(_dpState.month).padStart(2, '0')}`; + if (key === cur) dpRender(); + } + } + + function dpRender() { + const { year, month, selectedDate } = _dpState; + const key = `${year}-${String(month).padStart(2, '0')}`; + const loading = _dpState.loadingMonths.has(key); + const today = new Date().toISOString().slice(0, 10); + const minDate = dpMinDate(); + + // Update header + document.getElementById('dp-month-label').textContent = + new Date(year, month - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); + + // Prev button: disable if we're already at the minimum month + const minMonth = minDate.slice(0, 7); + const curMonth = key; + document.getElementById('dp-prev').disabled = curMonth <= minMonth; + + const grid = document.getElementById('dp-grid'); + + if (loading) { + grid.innerHTML = '
Checking availability…
'; + dpUpdateStatus(selectedDate); + return; + } + + const dows = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + let html = dows.map(d => `
${d}
`).join(''); + + const firstDay = new Date(year, month - 1, 1).getDay(); + const daysInMonth = new Date(year, month, 0).getDate(); + + // Leading empty cells + for (let i = 0; i < firstDay; i++) { + html += '
'; + } + + for (let d = 1; d <= daysInMonth; d++) { + const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + const isPast = dateStr < minDate; + const isBusy = dpIsBusy(dateStr); + const isToday = dateStr === today; + const isSelected = dateStr === selectedDate; + const isDisabled = isPast || isBusy; + + let cls = 'dp-day'; + if (isSelected) cls += ' dp-day--selected'; + else if (isBusy) cls += ' dp-day--busy dp-day--disabled'; + else if (isPast) cls += ' dp-day--past dp-day--disabled'; + else if (isToday) cls += ' dp-day--today'; + + const handler = isDisabled ? '' : `onclick="dpSelectDate('${dateStr}')"`; + html += `
${d}
`; + } + + grid.innerHTML = html; + dpUpdateStatus(selectedDate); + } + + function dpUpdateStatus(dateStr) { + const el = document.getElementById('dp-status'); + if (!dateStr) { el.textContent = ''; el.className = 'dp-status'; return; } + const friendly = new Date(dateStr + 'T12:00').toLocaleDateString('en-US', + { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); + el.textContent = 'Selected: ' + friendly; + el.className = 'dp-status dp-status--ok'; + } + + function dpSelectDate(dateStr) { + if (dpIsDisabled(dateStr)) return; + _dpState.selectedDate = dateStr; + document.getElementById('session-date').value = dateStr; + dpRender(); + } + + function dpNav(dir) { + let m = _dpState.month + dir; + let y = _dpState.year; + if (m > 12) { m = 1; y++; } + if (m < 1) { m = 12; y--; } + _dpState.month = m; + _dpState.year = y; + dpRender(); + // Pre-fetch this month and the next + dpFetchMonth(y, m); + const nextM = m === 12 ? 1 : m + 1; + const nextY = m === 12 ? y + 1 : y; + dpFetchMonth(nextY, nextM); + } + + function dpInit() { + const today = new Date(); + // Open to current month, or next month if today is in the last week + let y = today.getFullYear(); + let m = today.getMonth() + 1; + const daysLeft = new Date(y, m, 0).getDate() - today.getDate(); + if (daysLeft < 3) { m++; if (m > 12) { m = 1; y++; } } + + _dpState.year = y; + _dpState.month = m; + _dpState.selectedDate = ''; + document.getElementById('session-date').value = ''; + document.getElementById('dp-prev').onclick = () => dpNav(-1); + document.getElementById('dp-next').onclick = () => dpNav(1); + + dpRender(); // Render immediately (may show loading spinner) + dpFetchMonth(y, m); + const nextM = m === 12 ? 1 : m + 1; + const nextY = m === 12 ? y + 1 : y; + dpFetchMonth(nextY, nextM); + } + + // ── Booking Flow ─────────────────────────────────────── const BOOKING_STEPS = 7; @@ -2116,8 +2352,7 @@ A complete service agreement and model release will be provided at your session document.getElementById('contract-name').value = ''; bookingState.contractScrolled = false; bookingState.signatureDrawn = false; - document.getElementById('session-date').min = new Date().toISOString().split('T')[0]; - document.getElementById('session-date').value = ''; + dpInit(); renderSessionTypeCards(); renderSessionLengthCards(); renderLocationCards(); diff --git a/tests/api.spec.js b/tests/api.spec.js index ddee3cf..8234fb6 100644 --- a/tests/api.spec.js +++ b/tests/api.spec.js @@ -62,6 +62,24 @@ test.describe('Booking API — public routes', () => { // Either 404 (no template uploaded) or 200 (uploaded) — both are valid; just not 500 expect(res.status()).not.toBe(500); }); + + test('GET /api/availability returns busy array for valid month', async ({ request }) => { + const now = new Date(); + const res = await request.get(`/api/availability?year=${now.getFullYear()}&month=${now.getMonth() + 1}`); + expect(res.status()).toBe(200); + const json = await res.json(); + expect(Array.isArray(json.busy)).toBe(true); + expect(typeof json.configured).toBe('boolean'); + // Each busy entry must be a valid YYYY-MM-DD string + for (const d of json.busy) { + expect(d).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + }); + + test('GET /api/availability rejects missing params', async ({ request }) => { + const res = await request.get('/api/availability'); + expect(res.status()).toBe(400); + }); }); test.describe('Admin API — authentication', () => { diff --git a/tests/booking.spec.js b/tests/booking.spec.js index c4bb9a4..d003231 100644 --- a/tests/booking.spec.js +++ b/tests/booking.spec.js @@ -8,11 +8,26 @@ function futureDate(daysAhead = 60) { return d.toISOString().slice(0, 10); } -// Helper: open booking modal and fill step 1 (date) +// Helper: open booking modal and pick a date via the calendar UI. +// #session-date is now a hidden input set by dpSelectDate(); we use evaluate +// to set it directly (simulating a calendar day click) so tests stay fast. async function openAndPickDate(page, daysAhead = 60) { await page.locator('button:has-text("Book a Session")').first().click(); await page.locator('#booking-overlay').waitFor({ state: 'visible' }); - await page.locator('#session-date').fill(futureDate(daysAhead)); + const date = futureDate(daysAhead); + // Wait for calendar to render, then click the matching day cell + await page.locator('#dp-grid').waitFor({ state: 'visible' }); + // Try clicking the rendered day cell; fall back to JS if not visible yet + const dayCell = page.locator(`#dp-grid .dp-day[aria-label="${date}"]`); + if (await dayCell.isVisible().catch(() => false)) { + await dayCell.click(); + } else { + // Navigate to the correct month if needed, then set via JS + await page.evaluate(d => { + document.getElementById('session-date').value = d; + window._dpState && (window._dpState.selectedDate = d); + }, date); + } } test.describe('Booking wizard', () => { @@ -39,6 +54,47 @@ test.describe('Booking wizard', () => { await expect(page.locator('#booking-overlay')).not.toBeVisible(); }); + test('calendar renders month grid with day cells', async ({ page }) => { + await page.locator('button:has-text("Book a Session")').first().click(); + await page.locator('#dp-grid').waitFor({ state: 'visible' }); + await expect(page.locator('#dp-month-label')).toBeVisible(); + // At least 28 day cells should render + const days = page.locator('#dp-grid .dp-day'); + await expect(days.first()).toBeVisible({ timeout: 8_000 }); + expect(await days.count()).toBeGreaterThanOrEqual(28); + }); + + test('calendar prev/next navigation changes month', async ({ page }) => { + await page.locator('button:has-text("Book a Session")').first().click(); + await page.locator('#dp-grid').waitFor({ state: 'visible' }); + const labelBefore = await page.locator('#dp-month-label').textContent(); + await page.locator('#dp-next').click(); + const labelAfter = await page.locator('#dp-month-label').textContent(); + expect(labelAfter).not.toBe(labelBefore); + }); + + test('clicking a future date selects it and shows status', async ({ page }) => { + await page.locator('button:has-text("Book a Session")').first().click(); + await page.locator('#dp-grid').waitFor({ state: 'visible' }); + // Find first non-disabled available day + const availableDay = page.locator('#dp-grid .dp-day:not(.dp-day--disabled):not(.dp-day--other):not(.dp-day--past):not(.dp-day--busy)').first(); + await expect(availableDay).toBeVisible({ timeout: 8_000 }); + await availableDay.click(); + await expect(availableDay).toHaveClass(/dp-day--selected/); + await expect(page.locator('#dp-status')).toContainText(/Selected:/); + }); + + test('past dates are not selectable', async ({ page }) => { + await page.locator('button:has-text("Book a Session")').first().click(); + await page.locator('#dp-grid').waitFor({ state: 'visible' }); + const pastDay = page.locator('#dp-grid .dp-day--past').first(); + if (await pastDay.count() > 0) { + const before = await page.locator('#dp-status').textContent(); + await pastDay.click(); + await expect(page.locator('#dp-status')).toHaveText(before ?? ''); + } + }); + test('step 1 requires a date to advance', async ({ page }) => { await page.locator('button:has-text("Book a Session")').first().click(); // Click Next without a date — should stay on step 1