// app.jsx — Coach alpha shell. // Feature flag — hide the admin tab so we focus on the coaching flow first. // Set to true to expose admin requests + allowlist again. const ADMIN_TAB_VISIBLE = false; const { useEffect, useState, useCallback, useRef, useMemo } = React; const { useAuth, signIn, signUp, signOut, authedFetch } = window.CoachAuth; // ─── Loading screen ──────────────────────────────────────────────────────── // Used at boot + during stale-while-revalidate gaps. Intentionally NOT used // once we have any cached data — render the shell from cache and let async // refreshes happen invisibly. function LoadingScreen({ status = 'loading…' }) { return (
coach
); } // ─── Bootstrap cache (localStorage, stale-while-revalidate) ──────────────── // Calls /api/bootstrap, but reads from localStorage synchronously so the // shell renders instantly on subsequent visits. The fresh fetch updates // state in the background. Cache is namespaced by supabase user id so // switching accounts doesn't show stale data. const BOOTSTRAP_CACHE_KEY = 'coach-bootstrap-v1'; function readBootstrapCache(supabaseUserId) { try { const raw = localStorage.getItem(BOOTSTRAP_CACHE_KEY); if (!raw) return null; const obj = JSON.parse(raw); if (obj && obj.user && obj.user.supabase_user_id === supabaseUserId) { return obj; } } catch (_e) {} return null; } function writeBootstrapCache(data) { try { localStorage.setItem(BOOTSTRAP_CACHE_KEY, JSON.stringify(data)); } catch (_e) {} } function clearBootstrapCache() { try { localStorage.removeItem(BOOTSTRAP_CACHE_KEY); } catch (_e) {} } function useBootstrap(session) { // Hydrate synchronously from cache so the first render is instant. const supaId = session?.user?.id; const [data, setData] = useState(() => supaId ? readBootstrapCache(supaId) : null); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); const lastFetchedFor = useRef(null); const refresh = useCallback(async () => { if (!supaId) return; setRefreshing(true); try { const fresh = await authedFetch('/api/bootstrap'); setData(fresh); setError(null); writeBootstrapCache(fresh); } catch (e) { setError(e); } finally { setRefreshing(false); } }, [supaId]); // Refetch only when the actual user changes — NOT on token refresh, which // also changes the session reference but not the user. useEffect(() => { if (!supaId) { setData(null); setError(null); lastFetchedFor.current = null; return; } if (lastFetchedFor.current === supaId) return; lastFetchedFor.current = supaId; refresh(); }, [supaId, refresh]); return { data, error, refreshing, refresh }; } // ─── Auth screen ─────────────────────────────────────────────────────────── function AuthScreen() { const [mode, setMode] = useState('signin'); // 'signin' | 'signup' const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const [info, setInfo] = useState(null); const submit = async (e) => { e.preventDefault(); setError(null); setInfo(null); setPending(true); try { if (mode === 'signin') { await signIn(email, password); } else { const { user, session } = await signUp(email, password); if (user && !session) { setInfo("Check your email to confirm the address, then sign in."); setMode('signin'); } } } catch (e) { setError(e.message || String(e)); } finally { setPending(false); } }; return (
coach
{error &&
{error}
} {info &&
{info}
}
); } // ─── Garmin link card ────────────────────────────────────────────────────── function GarminCard({ onDataChanged, onLinked, initialStatus }) { // Hydrate from the parent's bootstrap data so we render content immediately // instead of showing "…" during a redundant /api/garmin/status fetch. const [status, setStatus] = useState(() => initialStatus ? { loading: false, ...initialStatus } : { loading: true, linked: false } ); const [syncStatus, setSyncStatus] = useState({ running: false }); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [pending, setPending] = useState(false); const [syncErr, setSyncErr] = useState(null); const [error, setError] = useState(null); const refresh = useCallback(async () => { try { const [s, ss] = await Promise.all([ authedFetch('/api/garmin/status'), authedFetch('/api/sync'), ]); setStatus({ loading: false, ...s }); setSyncStatus(ss); } catch (e) { setStatus({ loading: false, linked: false }); setError(e.message); } }, []); useEffect(() => { refresh(); }, [refresh]); // Poll while a sync is running. useEffect(() => { if (!syncStatus.running) return; let cancelled = false; const tick = async () => { try { const ss = await authedFetch('/api/sync'); if (cancelled) return; setSyncStatus(ss); if (!ss.running) { if (ss.last_error) setSyncErr(ss.last_error); await refresh(); if (onDataChanged) onDataChanged(); } } catch (e) { if (!cancelled) setSyncErr(e.message); } }; const id = setInterval(tick, 2500); return () => { cancelled = true; clearInterval(id); }; }, [syncStatus.running, refresh, onDataChanged]); const link = async (e) => { e.preventDefault(); setError(null); setPending(true); try { await authedFetch('/api/garmin/link', { method: 'POST', body: JSON.stringify({ username, password }), }); setUsername(''); setPassword(''); await refresh(); if (onLinked) onLinked(); } catch (e) { setError(e.message); } finally { setPending(false); } }; const unlink = async () => { if (!confirm('Disconnect Garmin? Your stored credentials will be removed.')) return; try { await authedFetch('/api/garmin/link', { method: 'DELETE' }); await refresh(); if (onLinked) onLinked(); } catch (e) { setError(e.message); } }; const sync = async () => { setSyncErr(null); try { const r = await authedFetch('/api/sync?days=14', { method: 'POST' }); setSyncStatus(r); } catch (e) { setSyncErr(e.message); } }; if (status.loading) return
garmin
; return (
garmin {status.linked ? (status.status || 'linked') : 'not linked'}
{status.linked ? (
status{status.status}
{status.last_refreshed_at && (
last refresh{new Date(status.last_refreshed_at).toLocaleString()}
)} {status.last_error && (
last error{status.last_error}
)} {syncStatus.last_summary && (
last sync {syncStatus.last_summary.metrics_written} days · {syncStatus.last_summary.new_sessions_actual} new sessions
)}
{syncErr &&
{syncErr}
}
) : (

Connect your Garmin Connect account so the coach can read your readiness, sleep, HRV, and activities. Credentials are encrypted at rest with a key stored only on this server.

{error &&
{error}
}
)}
); } // ─── Data card — what we have so far ─────────────────────────────────────── function DataCard({ refreshKey }) { const [dash, setDash] = useState(null); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; (async () => { try { const r = await authedFetch('/api/dashboard'); if (!cancelled) setDash(r); } catch (e) { if (!cancelled) setError(e.message); } })(); return () => { cancelled = true; }; }, [refreshKey]); if (error) return
data
{error}
; if (!dash) return
data
; const s = dash.summary || {}; // Last day with any metrics — useful for a quick "did the sync land?" check. const lastWithData = [...(dash.history || [])].reverse().find( (h) => Number.isFinite(h.garmin_readiness) || Number.isFinite(h.sleep_hours) ); return (
your data {s.days_with_metrics || 0} days
days with metrics{s.days_with_metrics || 0}
sessions logged{s.sessions_logged || 0}
{s.latest_metrics_date && (
latest date{s.latest_metrics_date}
)}
{lastWithData && (
{lastWithData.date}
)} {!lastWithData && (

No metrics yet. Connect Garmin and run a sync to backfill the last 14 days.

)}
); } function Metric({ label, value, unit, digits = 0 }) { const has = Number.isFinite(value); return (
{label}
{has ? Number(value).toFixed(digits) : '—'} {has && {unit}}
); } // ─── Goals card ──────────────────────────────────────────────────────────── // Goal taxonomy — drives the visual cards + the per-goal field map. // IDs are stored as `goals.goal_type`; `details_json` carries the rest. const GOAL_OPTIONS = [ { id: 'faster', icon: '⚡', name: 'get faster', desc: 'pace at current distances' }, { id: 'power', icon: '△', name: 'power & strides', desc: 'hills, plyos, economy' }, { id: 'endurance', icon: '∿', name: 'build endurance', desc: 'extend long sessions' }, { id: 'consistent', icon: '◷', name: 'stay consistent', desc: 'habit, maintenance' }, { id: 'weight', icon: '◐', name: 'lose weight', desc: 'calorie-aware plan' }, { id: 'race', icon: '◆', name: 'race prep', desc: 'event-targeted block' }, { id: 'return', icon: '↻', name: 'return from break', desc: 'deload + ramp' }, { id: 'muscle', icon: '◼', name: 'build muscle', desc: 'strength + hypertrophy' }, ]; const GOAL_FIELD_MAP = { faster: ['targetDistance', 'currentPb'], power: ['hillAccess', 'stridesExperience'], endurance: ['targetLong', 'currentLong'], consistent: ['minSessions', 'streakTarget'], weight: ['targetDelta', 'timeframe'], race: ['eventDate', 'eventDistance', 'targetTime', 'terrain'], return: ['breakDuration', 'breakReason'], muscle: ['split', 'gymAccess', 'trainingAge'], }; const FIELD_DEFS = { targetDistance: { label: 'target distance (km)', type: 'number', placeholder: 'e.g. 10' }, currentPb: { label: 'current PB at that distance', type: 'text', placeholder: 'e.g. 52:30' }, hillAccess: { label: 'hill access', type: 'select', options: ['yes — regular hills nearby', 'limited', 'flat only'] }, stridesExperience: { label: 'experience with strides/plyos', type: 'select', options: ['none', 'some', 'regular'] }, targetLong: { label: 'target long-session (min)', type: 'number', placeholder: 'e.g. 90' }, currentLong: { label: 'current longest (min)', type: 'number', placeholder: 'e.g. 55' }, minSessions: { label: 'minimum sessions/week', type: 'number', placeholder: 'e.g. 3' }, streakTarget: { label: 'streak target (weeks)', type: 'number', placeholder: 'e.g. 12' }, targetDelta: { label: 'target weight change (kg)', type: 'number', placeholder: 'e.g. -4' }, timeframe: { label: 'timeframe (weeks)', type: 'number', placeholder: 'e.g. 12' }, eventDate: { label: 'event date', type: 'date' }, eventDistance: { label: 'event distance (km)', type: 'number', placeholder: 'e.g. 21.1' }, targetTime: { label: 'target time (optional)', type: 'text', placeholder: 'e.g. 1:45:00' }, terrain: { label: 'terrain', type: 'select', options: ['road', 'trail', 'track', 'mixed'] }, breakDuration: { label: 'break length (weeks)', type: 'number', placeholder: 'e.g. 6' }, breakReason: { label: 'reason', type: 'select', options: ['injury', 'illness', 'life / travel', 'other'] }, split: { label: 'split preference', type: 'select', options: ['let coach decide', 'push-pull-legs', 'upper-lower', 'full body'] }, gymAccess: { label: 'equipment tier', type: 'select', options: ['full gym', 'home rack + barbell', 'dumbbells only', 'bodyweight'] }, trainingAge: { label: 'lifting experience', type: 'select', options: ['beginner (<1y)', 'intermediate (1-3y)', 'advanced (3y+)'] }, }; const CONSTRAINTS = ['knee', 'back', 'shoulder', 'hip', 'asthma', 'pregnancy', 'returning from injury', 'other']; const DAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; const DAY_LONG = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; const MAX_EQUIPMENT = 12; const MAX_OTHER = 50; function GoalCard({ onSaved, initialGoal }) { // null = still loading, false / object = ready const [active, setActive] = useState( initialGoal !== undefined ? (initialGoal || false) : null ); const [editing, setEditing] = useState(false); const [error, setError] = useState(null); const [pending, setPending] = useState(false); // Form state — mirrors the design's data model. const [goalId, setGoalId] = useState(null); const [fields, setFields] = useState({ sessions: '5' }); const [constraints, setConstraints] = useState(new Set()); const [otherConstraint, setOtherConstraint] = useState(''); const [days, setDays] = useState(new Set([0, 1, 2, 3, 4])); const [equipment, setEquipment] = useState([]); const [equipDraft, setEquipDraft] = useState(''); // Hydrate the form from an existing goal when entering edit mode (or when // first opening the form on a returning user with `details_json`). const hydrateFromActive = useCallback((g) => { if (!g) return; const d = g.details_json || {}; setGoalId(d.goal || g.goal_type || null); setFields({ sessions: '5', ...(d.fields || {}) }); setConstraints(new Set(d.constraints || [])); setOtherConstraint(d.otherConstraint || ''); setDays(new Set(d.days || [0, 1, 2, 3, 4])); setEquipment(Array.isArray(d.equipment) ? d.equipment : []); }, []); const refresh = useCallback(async () => { try { const r = await authedFetch('/api/goals/active'); setActive(r.goal || false); } catch (e) { setError(e.message); } }, []); useEffect(() => { refresh(); }, [refresh]); const startEdit = () => { hydrateFromActive(active); setEditing(true); }; const cancelEdit = () => { setEditing(false); setError(null); }; const selectGoal = (id) => { setGoalId(id); setError(null); }; const updateField = (key, value) => setFields((f) => ({ ...f, [key]: value })); const toggleConstraint = (c) => { setConstraints((prev) => { const next = new Set(prev); if (next.has(c)) { next.delete(c); if (c === 'other') setOtherConstraint(''); } else { next.add(c); } return next; }); }; const toggleDay = (i) => { setDays((prev) => { const next = new Set(prev); if (next.has(i)) next.delete(i); else next.add(i); return next; }); }; const commitEquip = () => { let v = equipDraft.trim().toLowerCase().slice(0, 20); v = v.replace(/[^a-z0-9 \-/]/g, '').trim(); if (!v) { setEquipDraft(''); return; } if (equipment.length >= MAX_EQUIPMENT) { setEquipDraft(''); return; } if (equipment.includes(v)) { setEquipDraft(''); return; } setEquipment([...equipment, v]); setEquipDraft(''); }; const onEquipKey = (e) => { if (e.key === ' ' || e.key === 'Enter' || e.key === ',') { e.preventDefault(); commitEquip(); } else if (e.key === 'Backspace' && equipDraft === '' && equipment.length > 0) { setEquipment(equipment.slice(0, -1)); } }; const removeEquip = (eq) => setEquipment(equipment.filter((x) => x !== eq)); // Live preview + validation warnings. const preview = useMemoPreview(goalId, fields, constraints, otherConstraint, days, equipment); const save = async (e) => { e.preventDefault(); if (!goalId) { setError('pick a goal first'); return; } setError(null); setPending(true); try { const cleanFields = Object.fromEntries( Object.entries(fields).filter(([, v]) => v !== '' && v != null) ); const details = { goal: goalId, fields: cleanFields, constraints: [...constraints], otherConstraint: otherConstraint || null, days: [...days].sort((a, b) => a - b), equipment, }; const body = { goal_type: goalId, details, activate: true, }; await authedFetch('/api/goals', { method: 'POST', body: JSON.stringify(body), }); setEditing(false); await refresh(); if (onSaved) onSaved(); } catch (e) { setError(e.message); } finally { setPending(false); } }; if (active === null) { return
goal
; } // ── Read-only summary mode (existing goal, not editing) ───────────── if (active && !editing) { const d = active.details_json || {}; const opt = GOAL_OPTIONS.find((g) => g.id === (d.goal || active.goal_type)); const goalLabel = opt ? opt.name : (active.goal_type || '').replace(/_/g, ' '); const dayList = (d.days && d.days.length) ? d.days.map((i) => DAY_LONG[i]).join(', ') : null; const constraintList = (d.constraints && d.constraints.length) ? d.constraints.map((c) => (c === 'other' && d.otherConstraint) ? `other (${d.otherConstraint})` : c).join(', ') : null; return (
active goal
goal{goalLabel}
{Object.entries(d.fields || {}).map(([k, v]) => (
{k}{String(v)}
))} {dayList &&
days{dayList}
} {constraintList &&
constraints{constraintList}
} {d.equipment && d.equipment.length > 0 && (
equipment{d.equipment.join(', ')}
)}
); } // ── Edit / first-set mode ───────────────────────────────────────────── const fieldIds = goalId ? (GOAL_FIELD_MAP[goalId] || []) : []; return (
set your goal {active && }
goal type
{GOAL_OPTIONS.map((g) => ( ))}
{goalId && (
{(() => { const rows = []; for (let i = 0; i < fieldIds.length; i += 2) { const pair = [fieldIds[i], fieldIds[i + 1]].filter(Boolean); rows.push(
{pair.map((fid) => { const def = FIELD_DEFS[fid]; return ( ); })}
); } return rows; })()}
)}
constraints tap any that apply
{CONSTRAINTS.map((c) => ( ))}
{constraints.has('other') && (
setOtherConstraint(e.target.value.replace(/[\r\n]+/g, ' ').slice(0, MAX_OTHER))} />
MAX_OTHER - 10 ? ' warn' : ''}`}> {otherConstraint.length} / {MAX_OTHER}
)}
available days
{DAY_LABELS.map((d, i) => ( ))}
equipment / environment type and press space · max 20 chars per item · leave empty to use equipment tier only
e.currentTarget.querySelector('input')?.focus()}> {equipment.map((eq) => ( {eq} ))} setEquipDraft(e.target.value)} onKeyDown={onEquipKey} onBlur={commitEquip} />
= MAX_EQUIPMENT ? ' warn' : ''}`}> {equipment.length} / {MAX_EQUIPMENT} items
{error &&
{error}
}
); } // Build the live "COACH PREVIEW" panel content. Returns React nodes + any // validation warnings derived from the current form state. function useMemoPreview(goalId, fields, constraints, otherConstraint, days, equipment) { return useMemo(() => { if (!goalId) { return { lines:
pick a goal to see how the coach will read your inputs.
, warnings: [], }; } const opt = GOAL_OPTIONS.find((g) => g.id === goalId); const fieldNodes = Object.entries(fields) .filter(([, v]) => v !== '' && v != null) .map(([k, v]) => (
{k}:{' '} {String(v)}
)); const dayList = [...days].sort((a, b) => a - b).map((i) => DAY_LONG[i]).join(', ') || '—'; const constraintItems = [...constraints].map((c) => c === 'other' && otherConstraint ? `other (${otherConstraint})` : c ); const constraintList = constraintItems.join(', ') || 'none'; const tier = fields.gymAccess; const equipNode = equipment.length > 0 ? equipment.join(', ') : tier ? — using equipment tier: {tier} : — none specified, coach will assume defaults; const lines = ( <>
goal:{' '} {opt?.name || goalId}
{fieldNodes}
days: {dayList}
constraints: {constraintList}
equipment: {equipNode}
); const warnings = []; if (goalId === 'race' && fields.eventDate) { const d = new Date(fields.eventDate); const daysOut = (d - new Date()) / (1000 * 60 * 60 * 24); if (daysOut < 14) warnings.push('event is less than 2 weeks out — coach will switch to taper-only mode.'); if (daysOut > 540) warnings.push('event is more than 18 months out — consider a sub-goal in the meantime.'); } if (fields.sessions && Number(fields.sessions) > 7) { warnings.push('7+ sessions/week is high — coach will plan double-day blocks carefully.'); } if (constraints.has('returning from injury') && goalId !== 'return') { warnings.push('flagged returning from injury — consider "return from break" goal for safer ramp.'); } return { lines, warnings }; }, [goalId, fields, constraints, otherConstraint, days, equipment]); } // ─── Share card — let friends/family follow your plan ───────────────────── function ShareCard() { const [status, setStatus] = useState(null); const [followers, setFollowers] = useState([]); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const refresh = useCallback(async () => { try { const [s, f] = await Promise.all([ authedFetch('/api/share/status'), authedFetch('/api/share/followers'), ]); setStatus(s); setFollowers(f.followers || []); } catch (e) { setError(e.message); } }, []); useEffect(() => { refresh(); }, [refresh]); const enable = async () => { setError(null); setPending(true); try { await authedFetch('/api/share/enable', { method: 'POST' }); await refresh(); } catch (e) { setError(e.message); } finally { setPending(false); } }; const disable = async () => { if (!confirm('Disable sharing? Your share link will 404 until you re-enable. Existing follower rows are kept.')) return; setError(null); setPending(true); try { await authedFetch('/api/share/disable', { method: 'POST' }); await refresh(); } catch (e) { setError(e.message); } finally { setPending(false); } }; const removeFollower = async (id) => { if (!confirm('Remove this follower? They lose access immediately.')) return; try { await authedFetch('/api/share/followers/' + id, { method: 'DELETE' }); await refresh(); } catch (e) { setError(e.message); } }; const shareUrl = status?.share_slug ? `${window.location.origin}/p/${status.share_slug}` : null; const copyLink = async () => { if (!shareUrl) return; try { await navigator.clipboard.writeText(shareUrl); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch (_e) { // clipboard write can fail in older browsers — fall back to selection prompt window.prompt('Copy this link:', shareUrl); } }; if (!status) return
share my plan
; const remaining = status.max_followers - status.active_count - status.pending_count; const atCap = remaining <= 0; return (
share my plan {status.enabled ? 'shared' : 'private'}
{!status.enabled && ( <>

Generate a public link friends and family can follow. They submit their email, confirm via a link, and get a Monday digest of your week. Up to {status.max_followers} followers. No biometrics or workout structure leaks — just commitment + totals.

)} {status.enabled && ( <>
active followers {status.active_count} / {status.max_followers}
{status.pending_count > 0 && (
pending confirm {status.pending_count}
)} {atCap && (
capacity at cap — remove a follower to free a slot
)}
{followers.length > 0 && (
    {followers.map((f) => (
  • {f.email_masked} {f.status} {f.status !== 'active' && ( {f.confirmed_at ? `confirmed ${f.confirmed_at.slice(0,10)}` : f.unsubscribed_at ? `unsub ${f.unsubscribed_at.slice(0,10)}` : `joined ${f.created_at.slice(0,10)}`} )}
  • ))}
)}
)} {error &&
{error}
}
); } // ─── Main shell ──────────────────────────────────────────────────────────── function Header({ user, view, setView, isAdmin }) { const showAdminTab = isAdmin && ADMIN_TAB_VISIBLE; return (
coach
{showAdminTab && ( )}
{user?.email}
); } // Compact "your setup" strip shown when both Garmin + Goal are configured. // Click "manage setup" to expand the GarminCard + GoalCard inline for editing. function StatusStrip({ garmin, goal, expanded, onToggle }) { const goalLabel = (goal.goal_type || 'goal').replace(/_/g, ' '); const phaseLabel = goal.current_phase ? goal.current_phase.replace(/_/g, ' ') : null; return (
garmin: {garmin.status || 'ok'} {goalLabel} {phaseLabel && <> · phase: {phaseLabel} }
); } function MainScreen({ session, bootstrap, refreshBootstrap }) { // Both views stay MOUNTED — toggling is CSS only — so switching admin ↔ // training doesn't refetch every card. const [view, setView] = useState('app'); // DataCard refresh key — bumped after a successful sync. const [dataKey, setDataKey] = useState(0); // Whether the GarminCard + GoalCard are visible in the ready stage. const [showSettings, setShowSettings] = useState(false); const isAdmin = !!bootstrap.user?.is_admin; const garmin = bootstrap.garmin || { linked: false }; const goal = bootstrap.goal; // After a mutation that changes garmin or goal state, refresh the // bootstrap so all stages re-evaluate. After a Garmin sync we ALSO bump // dataKey so the DataCard re-fetches /api/dashboard. const onDataChanged = useCallback(() => { setDataKey((k) => k + 1); refreshBootstrap(); }, [refreshBootstrap]); const garminOk = garmin.linked && garmin.status === 'ok'; const stage = !garminOk ? 'garmin' : !goal ? 'goal' : 'ready'; return (
{/* Admin view — only mounted when the feature flag is on, so the /api/admin/* calls don't fire on page load while we're hidden. */} {ADMIN_TAB_VISIBLE && (
{isAdmin && }
)} {/* Training view — three stages. */}
{stage === 'garmin' && (
Step 1 — connect your Garmin.
The coach reads your readiness, sleep, HRV, and activities directly from your watch. We can't help you train until this is connected.
)} {stage === 'goal' && (
Step 2 — what are you training for? · garmin connected ✓
Pick a goal so the coach knows what to optimize for. You can change it any time.
)} {stage === 'ready' && (
setShowSettings((v) => !v)} /> {showSettings && ( <> )} {/* ShareCard temporarily hidden — focus on the core Garmin-→-goal-→-chat loop first. Re-enable when ready. */} {/* */}
)}
); } function WaitlistScreen({ email }) { const [status, setStatus] = useState({ loading: true, request: null }); const [note, setNote] = useState(''); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const refresh = useCallback(async () => { try { const r = await authedFetch('/api/access/my-request'); setStatus({ loading: false, request: r.request }); } catch (e) { setStatus({ loading: false, request: null }); setError(e.message); } }, []); useEffect(() => { refresh(); }, [refresh]); const submit = async (e) => { e.preventDefault(); setError(null); setPending(true); try { await authedFetch('/api/access/request', { method: 'POST', body: JSON.stringify({ note: note || null }), }); await refresh(); } catch (e) { setError(e.message); } finally { setPending(false); } }; if (status.loading) return
; const req = status.request; return (
coach

you're on the waitlist

{req?.status === 'pending' && ( <>

Your request to use Coach as {email} is pending review. An admin will approve or deny it shortly. Come back to this page after they decide.

{req.request_note && (

Your note: {req.request_note}

)} )} {req?.status === 'denied' && (

Your previous request was declined{req.decision_note ? `: ${req.decision_note}` : '.'} {' '}You can submit a new request below.

)} {(!req || req.status === 'denied') && ( <>

Coach is invite-only right now. You're signed in as {email} — ask the admin to add you, or submit a request below.