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
+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>
}