BookATeeTime
Member Portal
Enter your invite code to access your dashboard
Your Invite Code
Member Portal
Welcome
Morning Game Extension · BCC
Your Assignment
🕐 No active assignment — check back before your next Saturday
Setup Status
✓ Ready
Account activated
Invite code confirmed — you're in the system
Extension installed
Load the .crx file in Chrome — takes 60 seconds · Install guide →
Extension version
Checking…
Ready to fire
Arm the sniper in the popup before your target booking window
How To Use It
1
Open the extension popup
Click the BookATeeTime icon in your Chrome toolbar. If you don't see it, click the puzzle piece icon → pin BookATeeTime.
2
Set your target date & time
On the Sniper tab, pick the Saturday you want to play and select your preferred tee time (e.g. 8:00 AM). Add your playing partners if you want them auto-filled.
3
Click "Arm Sniper" — then leave Chrome open
The extension calculates the exact moment ForeTees opens bookings for that date and sets a millisecond-precise alarm. Chrome does not need to be in front — just running.
4
Wake up to a notification
At the release moment the sniper fires. If your slot is available, it clicks instantly. If taken, it grabs the nearest time. You get a desktop notification either way — no babysitting needed.
Ready to book?
Open BCC ForeTees
Log in to BCC, then navigate to tee times
Log in to BCC ↗
into every member's portal page. function safe(s) { return String(s == null ? '' : s).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', }[c])); } const API = 'https://bookateetime-bcc.web.app/api'; const BCC_LOGIN = 'https://www.bethesdacountryclub.org/member-login'; const KEY = 'morning_game_code'; const NKEY = 'morning_game_name'; // C2a: member-portal JWT issued by /validate. Single source of truth for // authenticated calls from this page. Stored in sessionStorage (cleared // on tab close) so a revoked or expired session cannot survive a browser // restart. The corresponding install.html page writes the same key. const TKEY = 'bookateetime_member_jwt'; function getMemberToken() { try { return sessionStorage.getItem(TKEY) || ''; } catch (_) { return ''; } } function clearMemberToken() { try { sessionStorage.removeItem(TKEY); } catch (_) { /* noop */ } } function authHeaders(extra) { const h = Object.assign({}, extra || {}); const tok = getMemberToken(); if (tok) h['Authorization'] = 'Bearer ' + tok; return h; } // ── Boot ──────────────────────────────────────────── (async () => { // Pre-fill from URL ?code= const params = new URLSearchParams(location.search); const urlCode = params.get('code'); const savedCode = urlCode || localStorage.getItem(KEY); if (savedCode) { // C2a: dashboard requires the member-portal JWT minted by /validate. // If we have a code but no token (e.g. user navigated directly to // /member without going through /install in this session), force the // gate so submitCode() can re-run /validate and obtain the token. if (!getMemberToken()) { show('gate'); // Pre-fill the code so the user just clicks "Access Portal". try { document.getElementById('code-input').value = savedCode.toUpperCase(); } catch (_) {} document.getElementById('gate-error').textContent = 'Session expired — re-enter your code to continue.'; return; } show('loading'); await loadDashboard(savedCode.toUpperCase()); } else { show('gate'); } })(); // ── Code Gate ──────────────────────────────────────── document.getElementById('code-input').addEventListener('keydown', e => { if (e.key === 'Enter') submitCode(); }); async function submitCode() { const raw = document.getElementById('code-input').value.trim().toUpperCase(); const btn = document.getElementById('code-submit'); const err = document.getElementById('gate-error'); if (!raw) return; btn.disabled = true; btn.querySelector('span').textContent = 'Checking…'; err.textContent = ''; try { const r = await fetch(`${API}/validate?code=${encodeURIComponent(raw)}`); const d = await r.json(); if (d.ok) { // C2a: backend mints a member-portal JWT on successful /validate. // Without it the dashboard cannot make any authenticated call — // refuse to proceed rather than silently fall back to ?code= auth. if (!d.memberToken) { err.textContent = 'Session unavailable, please re-enter your code'; btn.disabled = false; btn.querySelector('span').textContent = 'Access Portal'; return; } try { sessionStorage.setItem(TKEY, d.memberToken); } catch (_) {} localStorage.setItem(KEY, raw); if (d.name) localStorage.setItem(NKEY, d.name); show('loading'); await loadDashboard(raw); } else { err.textContent = d.error || 'Invalid code.'; btn.disabled = false; btn.querySelector('span').textContent = 'Access Portal'; } } catch { err.textContent = 'Could not reach server. Try again.'; btn.disabled = false; btn.querySelector('span').textContent = 'Access Portal'; } } // ── Dashboard Loader ───────────────────────────────── async function loadDashboard(code) { // C2a: every authenticated call must send the member-portal JWT issued // by /validate. /config and /event are no longer accessible via // ?code= alone — a missing or revoked token must surface a 401, not // silently render a stale dashboard. if (!getMemberToken()) { localStorage.removeItem(KEY); localStorage.removeItem(NKEY); show('gate'); document.getElementById('gate-error').textContent = 'Session unavailable, please re-enter your code'; return; } try { const [valRes, cfgRes] = await Promise.all([ fetch(`${API}/validate?code=${encodeURIComponent(code)}`, { headers: authHeaders() }), fetch(`${API}/config?code=${encodeURIComponent(code)}`, { headers: authHeaders() }), ]); // 401/403 on /config means the JWT is revoked or expired. Drop it and // bounce to the gate so the user can re-validate. if (cfgRes.status === 401 || cfgRes.status === 403) { clearMemberToken(); localStorage.removeItem(KEY); localStorage.removeItem(NKEY); show('gate'); document.getElementById('gate-error').textContent = 'Your session has expired. Re-enter your code to continue.'; return; } const val = await valRes.json(); const cfg = await cfgRes.json(); if (!val.ok) { clearMemberToken(); localStorage.removeItem(KEY); localStorage.removeItem(NKEY); show('gate'); document.getElementById('gate-error').textContent = val.error || 'Code no longer valid.'; return; } const name = val.name || localStorage.getItem(NKEY) || 'Member'; localStorage.setItem(NKEY, name); localStorage.setItem(KEY, code); // M-12: If access has been revoked by an admin, hide the dashboard // assignment block and show a clear message. The /api/config endpoint // sets cfg.revoked === true when users/{code}.status === 'revoked'. if (cfg && cfg.revoked === true) { renderRevoked(name, code, cfg); show('dashboard'); return; } renderDashboard(name, code, cfg); show('dashboard'); // L-6: snapshot the current assignment + start the auto-refresh loop // so the open tab keeps in sync with backend state (admin cancel, // re-assignment, acknowledge from another device, revoke). window._lastAssignmentSnapshot = cfg && cfg.assignment ? { id: cfg.assignment.id, acknowledged: !!cfg.assignment.acknowledged, time: cfg.assignment.time, date: cfg.assignment.date, // W2-2: include firingDeviceId so the auto-refresh re-renders the // extension-armed status line when an extension wins the claim. firingDeviceId: cfg.assignment.firingDeviceId || null, } : null; startConfigAutoRefresh(code, name); } catch { // Network error — show gate with message const savedName = localStorage.getItem(NKEY); if (savedName) { // Try to render from cache renderDashboard(savedName, code, {}); show('dashboard'); // L-6: still arm the refresh loop so a transient network error // recovers automatically. startConfigAutoRefresh(code, savedName); } else { show('gate'); } } } // ── L-6: Auto-Refresh ─────────────────────────────── // Polls /api/config every 60s while the tab is visible so the dashboard // reflects admin cancels, re-assignments, acknowledgements, and revokes // without requiring a manual refresh. Pauses entirely when the tab is // hidden (visibilityState === 'hidden') and resumes on visibilitychange. let _autoRefreshTimer = null; let _autoRefreshCode = null; let _autoRefreshName = null; function startConfigAutoRefresh(code, name) { _autoRefreshCode = code; _autoRefreshName = name; if (_autoRefreshTimer) return; // already armed _autoRefreshTimer = setInterval(tickConfigAutoRefresh, 60_000); document.addEventListener('visibilitychange', tickConfigAutoRefresh); } async function tickConfigAutoRefresh() { // Skip while the tab is in the background — no point hammering /config. if (document.visibilityState === 'hidden') return; if (!_autoRefreshCode) return; try { // L-6 / C2a: auto-refresh must use the same Bearer-authenticated path // as the initial load. A 401 here means the JWT was revoked or expired // mid-session — drop it and bounce the user back to the gate so the // revoke (DB-3) is visibly enforced rather than masked by stale data. const r = await fetch(`${API}/config?code=${encodeURIComponent(_autoRefreshCode)}`, { headers: authHeaders(), }); if (r.status === 401 || r.status === 403) { clearMemberToken(); if (_autoRefreshTimer) { clearInterval(_autoRefreshTimer); _autoRefreshTimer = null; } localStorage.removeItem(KEY); localStorage.removeItem(NKEY); show('gate'); document.getElementById('gate-error').textContent = 'Your session has expired. Re-enter your code to continue.'; return; } if (!r.ok) return; // transient — try again next tick const cfg = await r.json(); // Revoked: collapse to the revoked card. if (cfg && cfg.revoked === true) { renderRevoked(_autoRefreshName || 'Member', _autoRefreshCode, cfg); return; } // No assignment but we previously had one — admin cancelled. if (!cfg || !cfg.assignment) { if (window._lastAssignmentSnapshot) { window._lastAssignmentSnapshot = null; renderDashboard(_autoRefreshName || 'Member', _autoRefreshCode, cfg || {}); } return; } const snap = window._lastAssignmentSnapshot; const newFiringDeviceId = cfg.assignment.firingDeviceId || null; const changed = !snap || snap.id !== cfg.assignment.id || snap.acknowledged !== !!cfg.assignment.acknowledged || snap.time !== cfg.assignment.time || snap.date !== cfg.assignment.date // W2-2: re-render when an extension wins the claim so the // "armed on extension" status line flips from waiting → armed. || snap.firingDeviceId !== newFiringDeviceId; if (changed) { window._lastAssignmentSnapshot = { id: cfg.assignment.id, acknowledged: !!cfg.assignment.acknowledged, time: cfg.assignment.time, date: cfg.assignment.date, firingDeviceId: newFiringDeviceId, }; renderDashboard(_autoRefreshName || 'Member', _autoRefreshCode, cfg); } } catch (_) { // Ignore transient errors — next tick will retry. } } // ── Render: Revoked ───────────────────────────────── // M-12: Member portal shown when /api/config returns { revoked: true }. // We hide the assignment card and Setup Status block entirely and replace // the dashboard body with a single message + revoke notice. The "I'm Ready" // (Arm Sniper) button is never rendered in this branch, satisfying the // disable-button requirement. All interpolated strings flow through safe(). function renderRevoked(name, code, cfg) { const firstName = (name || 'Member').split(/[\s,]+/)[0]; document.getElementById('member-name').textContent = `Hey, ${safe(firstName)}.`; document.getElementById('nav-name').textContent = safe(name); document.getElementById('signout-btn').style.display = ''; const main = document.getElementById('dashboard'); main.innerHTML = `
Member Portal
Hey, ${safe(firstName)}.
Morning Game Extension · BCC
Access Status
Revoked
🚫 Your access has been revoked. Please contact the club administrator.
`; } // ── Render ─────────────────────────────────────────── function renderDashboard(name, code, cfg) { // Name const firstName = name.split(/[\s,]+/)[0]; document.getElementById('member-name').textContent = `Hey, ${firstName}.`; document.getElementById('nav-name').textContent = name; document.getElementById('signout-btn').style.display = ''; // Assignment const asn = cfg.assignment; if (asn) { renderAssignment(asn, code); } else { document.getElementById('asn-content').innerHTML = `
🕐 No active assignment — check back before your next Saturday
`; } // Version const latestVer = cfg.latestVersion || '—'; document.getElementById('ver-sub').textContent = `Latest release: v${latestVer}`; } // Original button label — preserved so failure paths restore exact text. const ACK_BTN_LABEL = "🎯 Arm Sniper — I'm Ready"; function renderAssignment(asn, code) { const dateStr = formatDate(asn.date); // B3 cascade: asn.players is now [{personId, name, legacyName}] objects. // Defensive helper accepts both legacy strings and the new object shape. const playerNames = (asn.players || []).map(p => { if (typeof p === 'string') return p; if (p && typeof p === 'object') return p.name || p.legacyName || ''; return ''; }).filter(Boolean); const players = playerNames.map(name => `${safe(name)}` ).join(''); // Sniper rendering: post-B3 /config returns sniper:{personId,name}. const sniperName = (asn.sniper && asn.sniper.name) ? asn.sniper.name : 'You'; const sniperHtml = `
Sniper: ${safe(sniperName)}
`; const isAcknowledged = asn.acknowledged; document.getElementById('asn-status-badge').style.display = ''; document.getElementById('asn-status-badge').className = `badge ${isAcknowledged ? 'badge-green' : 'badge-amber'}`; document.getElementById('asn-status-badge').innerHTML = isAcknowledged ? '✓ Sniper Armed' : ' Arm Your Sniper'; // W2-2: Extension-armed status. /config now returns // assignment.firingDeviceId — non-null once any of the member's // Chrome extensions has won the claim transaction. We never expose // the raw deviceId; only "armed/waiting" UX. const dispatched = (asn.status === 'dispatched') || (asn.status === 'acknowledged'); let extensionStatusHtml = ''; if (dispatched) { if (asn.firingDeviceId) { extensionStatusHtml = '
' + '✓ Armed on extension — your Chrome will fire at the booking time.' + '
'; } else { extensionStatusHtml = '
' + '' + '⏳ Waiting for your extension to acknowledge — please ensure your Chrome is open at the booking time.' + '
'; } } document.getElementById('asn-content').innerHTML = `
${safe(dateStr)}
${safe(asn.time)}
${sniperHtml} ${players ? `
${players}
` : ''} ${asn.notes ? `
${safe(asn.notes)}
` : ''}
${isAcknowledged ? '
✅ You\'ve acknowledged this assignment — you\'re confirmed in the group
' : ``} Open ForeTees ↗
${extensionStatusHtml}
`; } // ── Acknowledge ────────────────────────────────────── function emitAckFailureAudit(payload) { // C2b: /event requires the member-portal JWT. If we don't have one // there's nothing useful to audit — skip rather than fire an // unauthenticated request that the backend will reject. if (!getMemberToken()) return; try { fetch(`${API}/event`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ type: 'BETA_AUDIT', auditType: 'BOOKING_FAILED', level: 'error', runId: 'member-portal-' + Date.now(), payload: Object.assign({ source: 'member.html', step: 'acknowledge', failureReason: 'ACKNOWLEDGE_REQUEST_FAILED', }, payload || {}), }), }).catch(() => {}); } catch (_) { /* swallow */ } } async function acknowledge(code, id) { const btn = document.getElementById('ack-btn'); const errEl = document.getElementById('ack-error'); if (!btn) return; btn.disabled = true; btn.textContent = 'Confirming…'; if (errEl) errEl.textContent = ''; const restoreButton = (msg) => { btn.disabled = false; btn.textContent = ACK_BTN_LABEL; if (errEl && msg) errEl.textContent = msg; }; try { const r = await fetch(`${API}/acknowledge`, { method: 'POST', // C2a/H1: member portal now holds a JWT; send it so the backend can // remove the legacy unauthenticated body-`code` path (H1). headers: authHeaders({ 'Content-Type': 'application/json' }), // Canonical contract is { id, code }. Backend now also accepts the // legacy { assignmentId } during the cache-bust transition window. body: JSON.stringify({ id, code }), }); let d = {}; try { d = await r.json(); } catch (_) { d = {}; } if (r.ok && d.ok) { btn.closest('.asn-actions').innerHTML = `
✅ Sniper armed — you're locked in for your slot.
`; document.getElementById('asn-status-badge').className = 'badge badge-green'; document.getElementById('asn-status-badge').textContent = '✓ Acknowledged'; return; } // Non-OK: classify and show specific message. // Backend canonical errorCodes (lib/auth.js + lib/responses.js): // AUTHENTICATION_REQUIRED, INVALID_OR_EXPIRED_TOKEN, TOKEN_REVOKED, // ACCOUNT_REVOKED. Pre-Wave-1 'AUTH_REQUIRED' kept as fallback. let msg; const ec = d && d.errorCode; const expiredCodes = new Set(['AUTHENTICATION_REQUIRED', 'INVALID_OR_EXPIRED_TOKEN', 'AUTH_REQUIRED']); const revokedCodes = new Set(['TOKEN_REVOKED', 'ACCOUNT_REVOKED']); if ((r.status === 401 || r.status === 403) && expiredCodes.has(ec)) { msg = 'Your session has expired. Re-enter your code to continue.'; // Drop local auth and bounce so the gate re-runs /validate. clearMemberToken(); } else if ((r.status === 401 || r.status === 403) && revokedCodes.has(ec)) { msg = 'Your access has been revoked. Contact the club administrator.'; clearMemberToken(); } else if (r.status === 401 || r.status === 403) { msg = (d && (d.error || d.message)) || 'Not authorized for this assignment.'; } else if (r.status === 404) { msg = 'This assignment no longer exists. Refresh the page.'; } else if (r.status >= 500) { msg = 'Server error. Try again in a moment.'; } else { msg = (d && (d.error || d.message)) || ('Request failed (HTTP ' + r.status + ').'); } emitAckFailureAudit({ httpStatus: r.status, errorCode: (d && d.errorCode) || null, assignmentId: id, }); restoreButton(msg); } catch (e) { emitAckFailureAudit({ httpStatus: 0, errorCode: 'NETWORK_ERROR', assignmentId: id, message: (e && e.message) || String(e), }); restoreButton('Network error. Check your connection and try again.'); } } // ── Sign Out ───────────────────────────────────────── function signOut() { clearMemberToken(); localStorage.removeItem(KEY); localStorage.removeItem(NKEY); location.href = '/member'; } // ── Helpers ────────────────────────────────────────── function show(view) { document.getElementById('gate').style.display = view === 'gate' ? 'flex' : 'none'; document.getElementById('loading').style.display = view === 'loading' ? 'flex' : 'none'; document.getElementById('dashboard').style.display = view === 'dashboard' ? 'block' : 'none'; } function formatDate(dateStr) { if (!dateStr) return '—'; const d = new Date(dateStr + 'T12:00:00'); return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }); }