feat(student): add learning tabs and Lab hint/assessment UX

StudentDashboard is now tabbed: My Learning (the original Phase 2A/2B flow, extracted
unchanged into MyLearningTab and extended with an overall percent-complete bar), Practice,
Assessments (available/active-with-live-countdown/history), Capstone (locked checklist ->
unlocked -> start/resume -> completed), Achievements (a flat dated list, not a leaderboard).

Inside the Lab UI: ProgressiveHints replaces the static hint array render when the Gateway has
stripped hints (detected via scenario.hints being undefined but hint_count present - standalone
dev mode never sees this, so it keeps the original behavior); a new top-level AssessmentBanner
shows a live countdown and completion count whenever a Platform assessment is running,
rendered alongside (not replacing) the pre-existing Lab-local exam-mode UI, which is untouched.
This commit is contained in:
2026-08-18 14:35:59 -05:00
parent 7e5f357b6f
commit d4db8f99f5
12 changed files with 680 additions and 140 deletions
+40
View File
@@ -0,0 +1,40 @@
import { useState, useEffect } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
const LABELS = {
stage_completed: 'Stage completed', curriculum_completed: 'Curriculum completed',
assessment_completed: 'Assessment completed', capstone_completed: 'Capstone completed',
}
const ICONS = {
stage_completed: '✅', curriculum_completed: '🎓', assessment_completed: '📝', capstone_completed: '🏁',
}
// Not a public certificate — a durable internal record. Phase 4/5 can render a certificate from
// these later (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §16); nothing here is shareable yet.
export default function AchievementsTab() {
const [achievements, setAchievements] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
api.achievements().then(setAchievements).catch((e) => setError(e.message))
}, [])
return (
<section className={styles.card}>
<h2>Achievements &amp; History</h2>
{error && <div className={styles.errorBox}>{error}</div>}
{!achievements ? <p className={styles.muted}>Loading…</p> : achievements.length === 0 ? <p className={styles.muted}>Nothing yet — complete a stage to see it here.</p> : (
achievements.map((a) => (
<div key={a.achievement_id} style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', padding: '0.5rem 0', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: '1.2rem' }}>{ICONS[a.achievement_type] || '🏅'}</span>
<div>
<div>{LABELS[a.achievement_type] || a.achievement_type}{a.stage_id ? `: ${a.stage_id}` : ''}{a.metadata?.title ? `: ${a.metadata.title}` : ''}</div>
<div className={styles.muted} style={{ fontSize: '0.78rem' }}>{new Date(a.achieved_at).toLocaleString()}</div>
</div>
</div>
))
)}
</section>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
// Available exams (assigned to the student's cohort), an active session if one is running (with
// a live countdown), and completed history. Starting an exam draws its scenario set
// server-side — the student works through it inside their existing Lab, the same one My
// Learning launches (see docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §9 for why a separate Lab
// per assessment isn't needed).
export default function AssessmentsTab() {
const [assessments, setAssessments] = useState(null)
const [active, setActive] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
api.assessments().then(setAssessments).catch((e) => setError(e.message))
api.activeAssessment().then(setActive).catch(() => {})
}, [])
useEffect(reload, [reload])
const handleStart = async (definitionId) => {
setBusy(true); setError(null)
try { await api.startAssessment(definitionId); reload() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
{active?.active && (
<section className={styles.card}>
<h2>Active Assessment</h2>
<p><strong>{active.title}</strong></p>
<CountdownLine expiresAt={active.expires_at} />
<table className={styles.dataTable} style={{ marginTop: '0.75rem' }}>
<thead><tr><th>#</th><th>Scenario</th><th>Status</th></tr></thead>
<tbody>
{active.scenarios.map((s) => (
<tr key={s.scenario_id}>
<td>{s.position + 1}</td><td>{s.scenario_id}</td>
<td>
{s.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`}>completed</span>
: <span className={`${styles.pill} ${styles.pillMuted}`}>{s.status}</span>}
</td>
</tr>
))}
</tbody>
</table>
<p className={styles.hint} style={{ marginTop: '0.75rem' }}>Launch your Lab from My Learning and work through these scenarios there — this list stays in sync automatically.</p>
</section>
)}
<section className={styles.card}>
<h2>Available Assessments</h2>
{!assessments ? <p className={styles.muted}>Loading…</p> : assessments.length === 0 ? <p className={styles.muted}>None assigned to your cohort yet.</p> : (
assessments.map((a) => {
const completedSessions = a.sessions.filter((s) => s.status === 'completed')
return (
<div key={a.assessment_definition_id} style={{ padding: '0.75rem 0', borderBottom: '1px solid var(--border)' }}>
<p><strong>{a.title}</strong> <span className={styles.muted}>· {a.duration_minutes} min</span></p>
{a.description && <p className={styles.muted} style={{ fontSize: '0.85rem' }}>{a.description}</p>}
{completedSessions.length > 0 && (
<p className={styles.hint}>Completed {completedSessions.length}× — best result: {completedSessions.some((s) => s.result?.passed) ? 'passed' : 'not yet passed'}</p>
)}
{!active?.active && (
<button className={styles.secondaryBtn} disabled={busy} onClick={() => handleStart(a.assessment_definition_id)}>
{busy ? 'Starting…' : 'Start'}
</button>
)}
</div>
)
})
)}
</section>
<section className={styles.card}>
<h2>History</h2>
{!assessments ? <p className={styles.muted}>Loading…</p> : (
<table className={styles.dataTable}>
<thead><tr><th>Assessment</th><th>Started</th><th>Status</th><th>Result</th></tr></thead>
<tbody>
{assessments.flatMap((a) => a.sessions).filter((s) => s.status !== 'not_started').sort((a, b) => new Date(b.started_at) - new Date(a.started_at)).map((s) => (
<tr key={s.assessment_session_id}>
<td>{assessments.find((a) => a.sessions.includes(s))?.title}</td>
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
<td><StatusPill status={s.status} /></td>
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
)
}
function StatusPill({ status }) {
const cls = status === 'completed' ? styles.pillOk : status === 'expired' || status === 'cancelled' ? styles.pillErr : styles.pillWarn
return <span className={`${styles.pill} ${cls}`}>{status}</span>
}
function CountdownLine({ expiresAt }) {
const [remaining, setRemaining] = useState(() => Math.max(0, new Date(expiresAt) - Date.now()))
useEffect(() => {
const id = setInterval(() => setRemaining(Math.max(0, new Date(expiresAt) - Date.now())), 1000)
return () => clearInterval(id)
}, [expiresAt])
const m = Math.floor(remaining / 60000), s = Math.floor((remaining % 60000) / 1000)
return <p className={styles.statusLine}><span className={styles.spinner} /> Time remaining: {m}m {s}s</p>
}
+89
View File
@@ -0,0 +1,89 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
const STAGE_NAMES = {
foundation: 'Foundation', workloads: 'Workloads', networking: 'Networking',
configuration: 'Configuration', troubleshooting: 'Troubleshooting', 'real-ops': 'RootNode: Real Ops',
}
export default function CapstoneTab() {
const [capstone, setCapstone] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
api.capstone().then(setCapstone).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
const handleStart = async () => {
setBusy(true); setError(null)
try { await api.startAssessment(capstone.assessment_definition_id); reload() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
if (error) return <div className={styles.errorBox}>{error}</div>
if (!capstone) return <p className={styles.muted}>Loading…</p>
if (capstone.status === 'locked') {
return (
<section className={styles.card}>
<h2>Capstone</h2>
<div className={styles.lockedBox}>
<p style={{ fontSize: '2rem' }}>🔒</p>
<p>Complete the rest of the curriculum to unlock the RootNode Capstone.</p>
</div>
<h3 style={{ marginTop: '1rem', fontSize: '0.9rem' }}>Prerequisites</h3>
{capstone.blocking_stage_ids.map((id) => (
<div key={id} className={styles.checklistItem}><span>⬜</span> {STAGE_NAMES[id] || id}</div>
))}
</section>
)
}
const activeSession = capstone.sessions.find((s) => s.status === 'in_progress');
const completedSession = capstone.sessions.find((s) => s.status === 'completed' && s.result?.passed);
return (
<section className={styles.card}>
<h2>Capstone {capstone.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`} style={{ marginLeft: '0.5rem' }}>completed</span> : <span className={`${styles.pill} ${styles.pillOk}`} style={{ marginLeft: '0.5rem' }}>unlocked</span>}</h2>
{capstone.definition && (
<>
<p><strong>{capstone.definition.title}</strong> <span className={styles.muted}>· {capstone.definition.duration_minutes} min</span></p>
<p className={styles.muted} style={{ fontSize: '0.85rem' }}>{capstone.definition.description}</p>
</>
)}
{completedSession ? (
<p className={styles.hint} style={{ marginTop: '1rem' }}>
Completed {new Date(completedSession.submitted_at).toLocaleString()} — {completedSession.result.scenarios_passed}/{completedSession.result.scenarios_total} scenarios passed.
</p>
) : activeSession ? (
<p className={styles.hint} style={{ marginTop: '1rem' }}>Your Capstone is in progress — launch your Lab from My Learning to continue.</p>
) : (
<button className={styles.primaryBtn} style={{ marginTop: '1rem' }} disabled={busy} onClick={handleStart}>
{busy ? 'Starting…' : 'Start Capstone'}
</button>
)}
{capstone.sessions.length > 0 && (
<>
<h3 style={{ marginTop: '1.5rem', fontSize: '0.9rem' }}>Attempt History</h3>
<table className={styles.dataTable}>
<thead><tr><th>Started</th><th>Status</th><th>Result</th></tr></thead>
<tbody>
{capstone.sessions.map((s) => (
<tr key={s.assessment_session_id}>
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
<td>{s.status}</td>
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</section>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
const POLL_MS = 2500
// The original Phase 2A/2B "Up Next" + Launch Lab / Reset flow, now extended with overall
// percent-complete (Phase 3B's rule-driven curriculum state, GET /api/curriculum/state, instead
// of the older /api/curriculum/path stats-only shape — same stage list, richer per-stage data).
export default function MyLearningTab() {
const [curriculum, setCurriculum] = useState(null)
const [envStatus, setEnvStatus] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const pollRef = useRef(null)
const loadCurriculum = useCallback(() => {
api.curriculumState().then(setCurriculum).catch((e) => setError(e.message))
}, [])
const loadEnvStatus = useCallback(() => {
api.environmentStatus().then(setEnvStatus).catch((e) => setError(e.message))
}, [])
useEffect(() => { loadCurriculum(); loadEnvStatus() }, [loadCurriculum, loadEnvStatus])
useEffect(() => {
const inFlight = envStatus && ['requested', 'provisioning', 'resetting'].includes(envStatus.status)
if (inFlight && !pollRef.current) {
pollRef.current = setInterval(loadEnvStatus, POLL_MS)
} else if (!inFlight && pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } }
}, [envStatus, loadEnvStatus])
const handleLaunch = async () => {
setBusy(true); setError(null)
try {
const result = await api.launchEnvironment()
setEnvStatus(result)
if (result.status === 'running' && result.launch_path) window.location.href = result.launch_path
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
const handleReset = async () => {
setBusy(true); setError(null)
try { setEnvStatus(await api.resetEnvironment()) } catch (e) { setError(e.message) } finally { setBusy(false) }
}
const currentStage = curriculum?.stages?.find((s) => s.id === curriculum.current_stage)
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<section className={styles.card}>
<h2>Current Stage</h2>
{!curriculum ? <p className={styles.muted}>Loading curriculum…</p> : (
<>
<div style={{ marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
<span>Overall progress</span><span className={styles.muted}>{curriculum.percent_complete}%</span>
</div>
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${curriculum.percent_complete}%` }} /></div>
</div>
<div className={styles.stageRow}>
{curriculum.stages.map((s) => (
<div key={s.id} className={`${styles.stagePill} ${s.id === curriculum.current_stage ? styles.stagePillActive : ''} ${s.locked ? styles.stagePillLocked : ''}`}>
<span>{s.icon}</span> {s.name}
<span className={styles.stageCount}>{s.completed}/{s.total}</span>
</div>
))}
</div>
{currentStage ? (
<p className={styles.nextUp}>
Up next: <strong>{currentStage.name}</strong>
{currentStage.next_scenario && <> — {currentStage.next_scenario}</>}
</p>
) : (
<p className={styles.nextUp}>All curriculum stages complete — your Capstone is waiting.</p>
)}
</>
)}
</section>
<section className={styles.card}>
<h2>Your Lab</h2>
<EnvironmentPanel status={envStatus} busy={busy} onLaunch={handleLaunch} onReset={handleReset} />
</section>
</>
)
}
function EnvironmentPanel({ status, busy, onLaunch, onReset }) {
const s = status?.status || 'none'
if (s === 'none' || s === 'destroyed') {
return (
<>
<p className={styles.muted}>No lab running yet.</p>
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
{busy ? 'Launching…' : 'Launch Lab'}
</button>
</>
)
}
if (['requested', 'provisioning', 'resetting'].includes(s)) {
return <p className={styles.statusLine}><span className={styles.spinner} /> {s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}</p>
}
if (s === 'running') {
return (
<>
<p className={styles.statusLine}><span className={styles.dotOk} /> Lab ready.</p>
<div className={styles.btnRow}>
<a className={styles.primaryBtn} href={`${status.launch_path}`}>Continue Lab</a>
<button className={styles.secondaryBtn} disabled={busy} onClick={onReset}>
{busy ? 'Working…' : 'Recreate Lab'}
</button>
</div>
<p className={styles.hint}>Recreating gives you a clean environment. Your progress is saved separately and is never lost.</p>
</>
)
}
if (s === 'error') {
return (
<>
<p className={styles.statusLine}><span className={styles.dotErr} /> {status.error_reason || 'Your lab hit a problem.'}</p>
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
{busy ? 'Launching…' : 'Try Again'}
</button>
</>
)
}
return <p className={styles.muted}>Unknown state ({s}).</p>
}
+22
View File
@@ -159,3 +159,25 @@
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Learning tabs (Phase 3B) ─────────────────────────────────────────── */
.tabNav { display: flex; gap: 1.25rem; padding: 0 2rem; border-bottom: 1px solid var(--border); }
.tab { color: var(--text-2); cursor: pointer; font-size: 0.9rem; background: none; border: none; padding: 0.75rem 0; }
.tabActive { color: var(--green); border-bottom: 2px solid var(--green); }
.progressBarTrack { background: var(--surface3); border-radius: 4px; height: 8px; overflow: hidden; }
.progressBarFill { background: var(--green); height: 8px; }
table.dataTable { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.dataTable th { text-align: left; color: var(--text-2); font-weight: 600; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border2); font-size: 0.72rem; text-transform: uppercase; }
.dataTable td { padding: 0.5rem 0.5rem; border-bottom: 1px solid var(--border); }
.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px; font-size: 0.75rem; font-family: var(--mono); }
.pillOk { background: var(--green-dim); color: var(--green); }
.pillWarn { background: var(--amber-dim); color: var(--amber); }
.pillErr { background: var(--red-dim); color: var(--red); }
.pillMuted { background: var(--surface3); color: var(--text-2); }
.lockedBox { text-align: center; padding: 2rem 1rem; color: var(--text-2); }
.checklistItem { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0; font-size: 0.88rem; }
+77
View File
@@ -0,0 +1,77 @@
import { useState, useEffect } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
// Available/completed scenarios (from curriculum state) + recent attempt history
// (GET /api/attempts/recent) — purely informational, no controls here; the actual work happens
// inside the Lab itself once launched from My Learning.
export default function PracticeTab() {
const [curriculum, setCurriculum] = useState(null)
const [attempts, setAttempts] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
api.curriculumState().then(setCurriculum).catch((e) => setError(e.message))
api.recentAttempts().then(setAttempts).catch((e) => setError(e.message))
}, [])
const allScenarios = curriculum?.stages.flatMap((s) => Array(s.total).fill(null).map((_, i) => i)) || []
const completedCount = curriculum?.stages.reduce((n, s) => n + s.completed, 0) || 0
const totalCount = allScenarios.length
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<section className={styles.card}>
<h2>Scenarios</h2>
{!curriculum ? <p className={styles.muted}>Loading…</p> : (
<table className={styles.dataTable}>
<thead><tr><th>Stage</th><th>Completed</th><th>Status</th></tr></thead>
<tbody>
{curriculum.stages.map((s) => (
<tr key={s.id}>
<td>{s.icon} {s.name}</td>
<td>{s.completed}/{s.total}</td>
<td>
{s.locked ? <span className={`${styles.pill} ${styles.pillMuted}`}>locked</span>
: s.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>complete</span>
: <span className={`${styles.pill} ${styles.pillWarn}`}>in progress</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
{curriculum && <p className={styles.hint} style={{ marginTop: '0.75rem' }}>{completedCount}/{totalCount} scenarios completed overall.</p>}
</section>
<section className={styles.card}>
<h2>Recent Attempts</h2>
{!attempts ? <p className={styles.muted}>Loading…</p> : attempts.length === 0 ? <p className={styles.muted}>No attempts yet — launch your Lab and pick a scenario.</p> : (
<table className={styles.dataTable}>
<thead><tr><th>Scenario</th><th>Status</th><th>Validations</th><th>Hints</th><th>Duration</th></tr></thead>
<tbody>
{attempts.map((a, i) => (
<tr key={i}>
<td>{a.scenario_id}</td>
<td>
{a.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`}>completed</span>
: <span className={`${styles.pill} ${styles.pillWarn}`}>in progress</span>}
</td>
<td>{a.validation_pass_count} passed, {a.validation_fail_count} failed</td>
<td>{a.hint_count || 0}</td>
<td>{a.duration_seconds ? formatDuration(a.duration_seconds) : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
)
}
function formatDuration(seconds) {
const m = Math.floor(seconds / 60), s = seconds % 60
return `${m}m ${s}s`
}
+29 -139
View File
@@ -1,78 +1,27 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
import MyLearningTab from './MyLearningTab'
import PracticeTab from './PracticeTab'
import AssessmentsTab from './AssessmentsTab'
import CapstoneTab from './CapstoneTab'
import AchievementsTab from './AchievementsTab'
const POLL_MS = 2500
const TABS = [
{ key: 'learning', label: 'My Learning' },
{ key: 'practice', label: 'Practice' },
{ key: 'assessments', label: 'Assessments' },
{ key: 'capstone', label: 'Capstone' },
{ key: 'achievements', label: 'Achievements' },
]
// C/D/F. Student home ("Up Next") + Launch Lab / Reset flow.
// Uses the existing GET /api/curriculum/path — the RootNode path (Foundation -> Workloads ->
// Networking -> Configuration -> Troubleshooting -> Real Ops -> Capstone) is not reinvented
// here, just rendered.
// Student home — tabbed as of Phase 3B (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §10): My
// Learning (the original Phase 2A/2B "Up Next" + Lab launch flow, unchanged in behavior, just
// extracted into its own component), Practice, Assessments, Capstone, Achievements/History.
export default function StudentDashboard({ student, onLogout }) {
const [curriculum, setCurriculum] = useState(null)
const [envStatus, setEnvStatus] = useState(null) // { status, launch_path?, error_reason? }
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const pollRef = useRef(null)
const loadCurriculum = useCallback(() => {
api.curriculumPath().then(setCurriculum).catch((e) => setError(e.message))
}, [])
const loadEnvStatus = useCallback(() => {
api.environmentStatus().then(setEnvStatus).catch((e) => setError(e.message))
}, [])
useEffect(() => { loadCurriculum(); loadEnvStatus() }, [loadCurriculum, loadEnvStatus])
// Poll while an environment is in-flight (provisioning/resetting) so the UI transitions to
// ready/error on its own — matches requirement D.4: provisioning -> starting -> ready -> error.
useEffect(() => {
const inFlight = envStatus && ['requested', 'provisioning', 'resetting'].includes(envStatus.status)
if (inFlight && !pollRef.current) {
pollRef.current = setInterval(loadEnvStatus, POLL_MS)
} else if (!inFlight && pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } }
}, [envStatus, loadEnvStatus])
const handleLaunch = async () => {
setBusy(true); setError(null)
try {
const result = await api.launchEnvironment()
setEnvStatus(result)
if (result.status === 'running' && result.launch_path) {
// No ?sid= needed anymore (Phase 2B) — the Gateway verifies ownership from the
// rn_session cookie itself, which travels with this navigation automatically. See
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
window.location.href = result.launch_path
}
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const handleReset = async () => {
setBusy(true); setError(null)
try {
const result = await api.resetEnvironment()
setEnvStatus(result)
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const [tab, setTab] = useState('learning')
const handleLogout = () => { api.logout().then(onLogout) }
const nextStage = curriculum?.stages?.find((s) => s.id === curriculum.next_stage)
const nextScenario = nextStage?.scenarios?.find((sc) => sc.status !== 'completed')
return (
<div className={styles.dashWrap}>
<header className={styles.dashHeader}>
@@ -83,78 +32,19 @@ export default function StudentDashboard({ student, onLogout }) {
</div>
</header>
{error && <div className={styles.errorBox}>{error}</div>}
<nav className={styles.tabNav}>
{TABS.map((t) => (
<button key={t.key} className={`${styles.tab} ${tab === t.key ? styles.tabActive : ''}`} onClick={() => setTab(t.key)}>
{t.label}
</button>
))}
</nav>
<section className={styles.card}>
<h2>Up Next</h2>
{!curriculum ? <p className={styles.muted}>Loading curriculum…</p> : (
<>
<div className={styles.stageRow}>
{curriculum.stages.map((s) => (
<div key={s.id} className={`${styles.stagePill} ${s.id === curriculum.next_stage ? styles.stagePillActive : ''} ${s.locked ? styles.stagePillLocked : ''}`}>
<span>{s.icon}</span> {s.name}
<span className={styles.stageCount}>{s.stats.completed}/{s.stats.total}</span>
</div>
))}
</div>
{nextStage && (
<p className={styles.nextUp}>
Next: <strong>{nextStage.name}</strong>
{nextScenario && <> — {nextScenario.title}</>}
</p>
)}
{!nextStage && <p className={styles.nextUp}>All unlocked stages complete — nice work.</p>}
</>
)}
</section>
<section className={styles.card}>
<h2>Your Lab</h2>
<EnvironmentPanel status={envStatus} busy={busy} onLaunch={handleLaunch} onReset={handleReset} />
</section>
{tab === 'learning' && <MyLearningTab />}
{tab === 'practice' && <PracticeTab />}
{tab === 'assessments' && <AssessmentsTab />}
{tab === 'capstone' && <CapstoneTab />}
{tab === 'achievements' && <AchievementsTab />}
</div>
)
}
function EnvironmentPanel({ status, busy, onLaunch, onReset }) {
const s = status?.status || 'none'
if (s === 'none' || s === 'destroyed') {
return (
<>
<p className={styles.muted}>No lab running yet.</p>
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
{busy ? 'Launching…' : 'Launch Lab'}
</button>
</>
)
}
if (['requested', 'provisioning', 'resetting'].includes(s)) {
return <p className={styles.statusLine}><span className={styles.spinner} /> {s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}</p>
}
if (s === 'running') {
return (
<>
<p className={styles.statusLine}><span className={styles.dotOk} /> Lab ready.</p>
<div className={styles.btnRow}>
<a className={styles.primaryBtn} href={`${status.launch_path}`}>Continue Lab</a>
<button className={styles.secondaryBtn} disabled={busy} onClick={onReset}>
{busy ? 'Working…' : 'Recreate Lab'}
</button>
</div>
<p className={styles.hint}>Recreating gives you a clean environment. Your progress is saved separately and is never lost.</p>
</>
)
}
if (s === 'error') {
return (
<>
<p className={styles.statusLine}><span className={styles.dotErr} /> {status.error_reason || 'Your lab hit a problem.'}</p>
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
{busy ? 'Launching…' : 'Try Again'}
</button>
</>
)
}
return <p className={styles.muted}>Unknown state ({s}).</p>
}
+13
View File
@@ -66,8 +66,21 @@ export const api = {
whoami: () => fetch('/api/session/whoami').then(json),
curriculumPath: () => fetch('/api/curriculum/path').then(json),
curriculumState: () => fetch('/api/curriculum/state').then(json),
environmentStatus: () => fetch('/api/environment/status').then(json),
launchEnvironment: () => mutate('/api/environment/launch'),
resetEnvironment: () => mutate('/api/environment/reset'),
recentAttempts: () => fetch('/api/attempts/recent').then(json),
assessments: () => fetch('/api/assessments').then(json),
activeAssessment: () => fetch('/api/assessments/active').then(json),
assessmentDetail: (sessionId) => fetch(`/api/assessments/${sessionId}`).then(json),
startAssessment: (definitionId) => mutate(`/api/assessments/${definitionId}/start`),
submitAssessment: (sessionId) => mutate(`/api/assessments/${sessionId}/submit`),
capstone: () => fetch('/api/capstone').then(json),
achievements: () => fetch('/api/achievements').then(json),
}