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:
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user