// 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
{status}
);
}
// ─── 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 (
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}
}
) : (
)}
);
}
// ─── 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 (
);
}
// 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.
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}
);
}
// 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.
);
}
function App() {
const { loading: authLoading, session } = useAuth();
const boot = useBootstrap(session);
if (authLoading) return ;
if (!session) return ;
// Not on the invite allowlist — Supabase signed them up, our backend says no.
if (boot.error?.body?.detail?.error === 'not_invited') {
return ;
}
// Hard error AND no cached fallback — show retry/sign-out.
if (boot.error && !boot.data) {
return (
coach
Could not load your profile: {boot.error.message}
);
}
// No cache + no data yet → first-ever load for this user. Brief spinner.
// Subsequent visits hydrate from cache and skip this entirely.
if (!boot.data) return ;
return ;
}
// Boot the supabase client first, then mount React.
window.CoachAuth.boot()
.then(() => {
ReactDOM.createRoot(document.getElementById('root')).render();
})
.catch((e) => {
document.getElementById('root').innerHTML =
'
' +
'Boot failed: ' + (e.message || e) +
'\n\nCheck that the FastAPI backend is running and /api/config returns SUPABASE_URL + SUPABASE_PUBLISHABLE_KEY.' +
'