diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index a51b8dd..ece06d9 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -8,6 +8,7 @@ import ExamTimer from './components/ExamTimer'
import ExamReport from './components/ExamReport'
import ExamStartModal from './components/ExamStartModal'
import ExamHistory from './components/ExamHistory'
+import AssessmentBanner from './components/AssessmentBanner'
import styles from './App.module.css'
const MIN_SIDEBAR_W = 180
@@ -303,6 +304,7 @@ export default function App() {
return (
setShowHistory(true)} />
+
{/* Bundle navigation bar */}
{
+ let cancelled = false
+ function poll() {
+ fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
+ if (!cancelled) setActive(d.active ? d : null)
+ }).catch(() => { if (!cancelled) setActive(null) })
+ }
+ poll()
+ const id = setInterval(poll, 15000)
+ return () => { cancelled = true; clearInterval(id) }
+ }, [])
+
+ useEffect(() => {
+ if (!active) return
+ const tick = () => setRemaining(Math.max(0, new Date(active.expires_at) - Date.now()))
+ tick()
+ const id = setInterval(tick, 1000)
+ return () => clearInterval(id)
+ }, [active])
+
+ if (!active) return null
+ const m = Math.floor(remaining / 60000), s = Math.floor((remaining % 60000) / 1000)
+ const completedCount = active.scenarios.filter((sc) => sc.status === 'completed').length
+
+ return (
+
+ 🎯 Assessment Mode: {active.title}
+ {completedCount}/{active.scenarios.length} scenarios complete
+ ⏱ {m}m {s}s remaining
+
+ )
+}
diff --git a/frontend/src/components/ProgressiveHints.jsx b/frontend/src/components/ProgressiveHints.jsx
new file mode 100644
index 0000000..47750a4
--- /dev/null
+++ b/frontend/src/components/ProgressiveHints.jsx
@@ -0,0 +1,91 @@
+import { useState, useEffect, useCallback } from 'react'
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import styles from './ScenarioPanel.module.css'
+
+// Phase 3B progressive hints — used when the Gateway has stripped the full `hints` array from
+// the scenario payload (the multi-student, Platform-mediated deployment; see
+// platform/gateway/src/server.js's GET /lab/:token/api/scenarios/:scenarioId). Hints are
+// fetched one at a time from the Platform API, server-enforced sequential (can't skip to hint
+// 3), and every reveal is recorded against the student's attempt — none of that is possible
+// with the old fully-preloaded array this replaces for this deployment mode.
+//
+// In standalone/single-container dev mode there's no Gateway rewrite, so `scenario.hints` is
+// still the full array and ScenarioPanel never renders this component at all — see the
+// `hintCount != null` check at the call site.
+export default function ProgressiveHints({ scenarioId, totalHints }) {
+ const [revealed, setRevealed] = useState([]) // [{level, title, body, command}]
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [copiedIdx, setCopiedIdx] = useState(null)
+ const [assessmentHintPolicy, setAssessmentHintPolicy] = useState(null) // null = not in an assessment; 'free'|'disabled'|'flagged'
+
+ // A scenario/environment change resets local reveal state — the server is still the source
+ // of truth (GET /attempt would show the real highest_hint_level), this is just what's
+ // rendered locally this session. Also checks whether this scenario is part of an active
+ // Platform assessment, purely to show the policy indicator pre-emptively (the server enforces
+ // the policy regardless, on every reveal call — this is UX, not the security boundary).
+ useEffect(() => {
+ setRevealed([]); setError(null); setAssessmentHintPolicy(null)
+ fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
+ if (d.active && d.scenarios?.some((s) => s.scenario_id === scenarioId)) setAssessmentHintPolicy(d.hint_policy)
+ }).catch(() => {})
+ }, [scenarioId])
+
+ const revealNext = useCallback(async () => {
+ setLoading(true); setError(null)
+ try {
+ const r = await fetch(`api/scenarios/${scenarioId}/hint`, { method: 'POST' })
+ const d = await r.json()
+ if (!r.ok) { setError(d.message || d.error || 'Could not reveal a hint.'); return }
+ setRevealed((h) => [...h, { level: d.level, ...d.hint }])
+ } catch {
+ setError('Could not reach the platform to reveal a hint.')
+ } finally {
+ setLoading(false)
+ }
+ }, [scenarioId])
+
+ function copyCmd(cmd, idx) {
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard.writeText(cmd).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1800) })
+ }
+ }
+
+ if (totalHints === 0) return No hints available for this scenario.
+
+ if (assessmentHintPolicy === 'disabled' && revealed.length === 0) {
+ return Hints Disabled — this assessment does not allow hints.
+ }
+
+ return (
+
+ {assessmentHintPolicy === 'flagged' &&
Using a hint will be recorded.
}
+ {revealed.map((hint, i) => (
+
+
+
+ Hint {hint.level}
+ {hint.title}
+
+
+
+
{hint.body}
+ {hint.command && (
+
+
{hint.command}
+
copyCmd(hint.command, i)}>{copiedIdx === i ? '✓ Copied' : 'Copy'}
+
+ )}
+
+
+ ))}
+ {error &&
⚠ {error}
}
+ {revealed.length < totalHints && (
+
+ {loading ? 'Revealing…' : `Reveal Hint ${revealed.length + 1} of ${totalHints}`}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ScenarioPanel.jsx b/frontend/src/components/ScenarioPanel.jsx
index b9bbbc5..853181b 100644
--- a/frontend/src/components/ScenarioPanel.jsx
+++ b/frontend/src/components/ScenarioPanel.jsx
@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import styles from './ScenarioPanel.module.css'
import { useConfirm } from './useConfirm'
+import ProgressiveHints from './ProgressiveHints'
// Inline markdown: renders without a wrapping — safe for buttons/spans
const inlineComponents = {
@@ -48,6 +49,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
setHintsRevealed([])
}, [scenario?.id])
+ // Phase 3B attempt telemetry — fire-and-forget "scenario started" so the Platform can record
+ // a real started_at for this working session. Only meaningful under the Platform-multiplied
+ // deployment (routed through the Gateway); 404s harmlessly in standalone/single-container dev
+ // mode, where there's no Platform API to reach at all.
+ useEffect(() => {
+ if (!scenario?.id) return
+ fetch(`api/scenarios/${scenario.id}/start`, { method: 'POST' }).catch(() => {})
+ }, [scenario?.id])
+
const timeSpentRef = useRef(0)
const lastScenarioIdRef = useRef(null)
@@ -341,7 +351,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
onClick={() => setTab(t)}
>
{t === 'problem' ? '📄 Problem'
- : t === 'hints' ? `💡 Hints (${scenario.hints?.length || 0})`
+ : t === 'hints' ? `💡 Hints (${scenario.hints?.length ?? scenario.hint_count ?? 0})`
: '✓ Validate'}
))}
@@ -448,6 +458,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
{/* HINTS TAB */}
{tab === 'hints' && (
+ {/* Phase 3B: the Gateway strips `hints` from the payload under the Platform-
+ multiplied deployment (see platform/gateway/src/server.js) — hint_count survives
+ as the signal to switch to server-gated progressive reveal instead of the old
+ fully-preloaded array. Standalone/dev mode still has the full array (no Gateway
+ rewrite happens there) and keeps the original behavior untouched below. */}
+ {scenario.hints === undefined && typeof scenario.hint_count === 'number' ? (
+
+ ) : (
+ <>
{scenario.hints?.length === 0 && (
No hints available for this scenario.
)}
@@ -483,6 +502,8 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
)
})}
+ >
+ )}
)}
diff --git a/frontend/src/platform/AchievementsTab.jsx b/frontend/src/platform/AchievementsTab.jsx
new file mode 100644
index 0000000..0b3a545
--- /dev/null
+++ b/frontend/src/platform/AchievementsTab.jsx
@@ -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 (
+
+ Achievements & History
+ {error && {error}
}
+ {!achievements ? Loading…
: achievements.length === 0 ? Nothing yet — complete a stage to see it here.
: (
+ achievements.map((a) => (
+
+
{ICONS[a.achievement_type] || '🏅'}
+
+
{LABELS[a.achievement_type] || a.achievement_type}{a.stage_id ? `: ${a.stage_id}` : ''}{a.metadata?.title ? `: ${a.metadata.title}` : ''}
+
{new Date(a.achieved_at).toLocaleString()}
+
+
+ ))
+ )}
+
+ )
+}
diff --git a/frontend/src/platform/AssessmentsTab.jsx b/frontend/src/platform/AssessmentsTab.jsx
new file mode 100644
index 0000000..2a02e1a
--- /dev/null
+++ b/frontend/src/platform/AssessmentsTab.jsx
@@ -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 && {error}
}
+
+ {active?.active && (
+
+ Active Assessment
+ {active.title}
+
+
+ # Scenario Status
+
+ {active.scenarios.map((s) => (
+
+ {s.position + 1} {s.scenario_id}
+
+ {s.status === 'completed' ? completed
+ : {s.status} }
+
+
+ ))}
+
+
+ Launch your Lab from My Learning and work through these scenarios there — this list stays in sync automatically.
+
+ )}
+
+
+ Available Assessments
+ {!assessments ? Loading…
: assessments.length === 0 ? None assigned to your cohort yet.
: (
+ assessments.map((a) => {
+ const completedSessions = a.sessions.filter((s) => s.status === 'completed')
+ return (
+
+
{a.title} · {a.duration_minutes} min
+ {a.description &&
{a.description}
}
+ {completedSessions.length > 0 && (
+
Completed {completedSessions.length}× — best result: {completedSessions.some((s) => s.result?.passed) ? 'passed' : 'not yet passed'}
+ )}
+ {!active?.active && (
+
handleStart(a.assessment_definition_id)}>
+ {busy ? 'Starting…' : 'Start'}
+
+ )}
+
+ )
+ })
+ )}
+
+
+
+ History
+ {!assessments ? Loading…
: (
+
+ Assessment Started Status Result
+
+ {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) => (
+
+ {assessments.find((a) => a.sessions.includes(s))?.title}
+ {s.started_at ? new Date(s.started_at).toLocaleString() : '—'}
+
+ {s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}
+
+ ))}
+
+
+ )}
+
+ >
+ )
+}
+
+function StatusPill({ status }) {
+ const cls = status === 'completed' ? styles.pillOk : status === 'expired' || status === 'cancelled' ? styles.pillErr : styles.pillWarn
+ return {status}
+}
+
+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 Time remaining: {m}m {s}s
+}
diff --git a/frontend/src/platform/CapstoneTab.jsx b/frontend/src/platform/CapstoneTab.jsx
new file mode 100644
index 0000000..5f47297
--- /dev/null
+++ b/frontend/src/platform/CapstoneTab.jsx
@@ -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 {error}
+ if (!capstone) return Loading…
+
+ if (capstone.status === 'locked') {
+ return (
+
+ Capstone
+
+
🔒
+
Complete the rest of the curriculum to unlock the RootNode Capstone.
+
+ Prerequisites
+ {capstone.blocking_stage_ids.map((id) => (
+ ⬜ {STAGE_NAMES[id] || id}
+ ))}
+
+ )
+ }
+
+ const activeSession = capstone.sessions.find((s) => s.status === 'in_progress');
+ const completedSession = capstone.sessions.find((s) => s.status === 'completed' && s.result?.passed);
+
+ return (
+
+ Capstone {capstone.status === 'completed' ? completed : unlocked }
+ {capstone.definition && (
+ <>
+ {capstone.definition.title} · {capstone.definition.duration_minutes} min
+ {capstone.definition.description}
+ >
+ )}
+
+ {completedSession ? (
+
+ Completed {new Date(completedSession.submitted_at).toLocaleString()} — {completedSession.result.scenarios_passed}/{completedSession.result.scenarios_total} scenarios passed.
+
+ ) : activeSession ? (
+ Your Capstone is in progress — launch your Lab from My Learning to continue.
+ ) : (
+
+ {busy ? 'Starting…' : 'Start Capstone'}
+
+ )}
+
+ {capstone.sessions.length > 0 && (
+ <>
+ Attempt History
+
+ Started Status Result
+
+ {capstone.sessions.map((s) => (
+
+ {s.started_at ? new Date(s.started_at).toLocaleString() : '—'}
+ {s.status}
+ {s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}
+
+ ))}
+
+
+ >
+ )}
+
+ )
+}
diff --git a/frontend/src/platform/MyLearningTab.jsx b/frontend/src/platform/MyLearningTab.jsx
new file mode 100644
index 0000000..7fbd703
--- /dev/null
+++ b/frontend/src/platform/MyLearningTab.jsx
@@ -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 && {error}
}
+
+
+ Current Stage
+ {!curriculum ? Loading curriculum…
: (
+ <>
+
+
+ Overall progress {curriculum.percent_complete}%
+
+
+
+
+ {curriculum.stages.map((s) => (
+
+ {s.icon} {s.name}
+ {s.completed}/{s.total}
+
+ ))}
+
+ {currentStage ? (
+
+ Up next: {currentStage.name}
+ {currentStage.next_scenario && <> — {currentStage.next_scenario}>}
+
+ ) : (
+ All curriculum stages complete — your Capstone is waiting.
+ )}
+ >
+ )}
+
+
+
+ >
+ )
+}
+
+function EnvironmentPanel({ status, busy, onLaunch, onReset }) {
+ const s = status?.status || 'none'
+
+ if (s === 'none' || s === 'destroyed') {
+ return (
+ <>
+ No lab running yet.
+
+ {busy ? 'Launching…' : 'Launch Lab'}
+
+ >
+ )
+ }
+ if (['requested', 'provisioning', 'resetting'].includes(s)) {
+ return {s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}
+ }
+ if (s === 'running') {
+ return (
+ <>
+ Lab ready.
+
+ Recreating gives you a clean environment. Your progress is saved separately and is never lost.
+ >
+ )
+ }
+ if (s === 'error') {
+ return (
+ <>
+ {status.error_reason || 'Your lab hit a problem.'}
+
+ {busy ? 'Launching…' : 'Try Again'}
+
+ >
+ )
+ }
+ return Unknown state ({s}).
+}
diff --git a/frontend/src/platform/Platform.module.css b/frontend/src/platform/Platform.module.css
index b7cc8d3..32a8cf9 100644
--- a/frontend/src/platform/Platform.module.css
+++ b/frontend/src/platform/Platform.module.css
@@ -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; }
diff --git a/frontend/src/platform/PracticeTab.jsx b/frontend/src/platform/PracticeTab.jsx
new file mode 100644
index 0000000..a98d09c
--- /dev/null
+++ b/frontend/src/platform/PracticeTab.jsx
@@ -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 && {error}
}
+
+ Scenarios
+ {!curriculum ? Loading…
: (
+
+ Stage Completed Status
+
+ {curriculum.stages.map((s) => (
+
+ {s.icon} {s.name}
+ {s.completed}/{s.total}
+
+ {s.locked ? locked
+ : s.complete ? complete
+ : in progress }
+
+
+ ))}
+
+
+ )}
+ {curriculum && {completedCount}/{totalCount} scenarios completed overall.
}
+
+
+
+ Recent Attempts
+ {!attempts ? Loading…
: attempts.length === 0 ? No attempts yet — launch your Lab and pick a scenario.
: (
+
+ Scenario Status Validations Hints Duration
+
+ {attempts.map((a, i) => (
+
+ {a.scenario_id}
+
+ {a.status === 'completed' ? completed
+ : in progress }
+
+ {a.validation_pass_count} passed, {a.validation_fail_count} failed
+ {a.hint_count || 0}
+ {a.duration_seconds ? formatDuration(a.duration_seconds) : '—'}
+
+ ))}
+
+
+ )}
+
+ >
+ )
+}
+
+function formatDuration(seconds) {
+ const m = Math.floor(seconds / 60), s = seconds % 60
+ return `${m}m ${s}s`
+}
diff --git a/frontend/src/platform/StudentDashboard.jsx b/frontend/src/platform/StudentDashboard.jsx
index d8185f4..2796188 100644
--- a/frontend/src/platform/StudentDashboard.jsx
+++ b/frontend/src/platform/StudentDashboard.jsx
@@ -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 (
@@ -83,78 +32,19 @@ export default function StudentDashboard({ student, onLogout }) {
- {error && {error}
}
+
+ {TABS.map((t) => (
+ setTab(t.key)}>
+ {t.label}
+
+ ))}
+
-
- Up Next
- {!curriculum ? Loading curriculum…
: (
- <>
-
- {curriculum.stages.map((s) => (
-
- {s.icon} {s.name}
- {s.stats.completed}/{s.stats.total}
-
- ))}
-
- {nextStage && (
-
- Next: {nextStage.name}
- {nextScenario && <> — {nextScenario.title}>}
-
- )}
- {!nextStage && All unlocked stages complete — nice work.
}
- >
- )}
-
-
-
+ {tab === 'learning' && }
+ {tab === 'practice' && }
+ {tab === 'assessments' && }
+ {tab === 'capstone' && }
+ {tab === 'achievements' && }
)
}
-
-function EnvironmentPanel({ status, busy, onLaunch, onReset }) {
- const s = status?.status || 'none'
-
- if (s === 'none' || s === 'destroyed') {
- return (
- <>
- No lab running yet.
-
- {busy ? 'Launching…' : 'Launch Lab'}
-
- >
- )
- }
- if (['requested', 'provisioning', 'resetting'].includes(s)) {
- return {s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}
- }
- if (s === 'running') {
- return (
- <>
- Lab ready.
-
- Recreating gives you a clean environment. Your progress is saved separately and is never lost.
- >
- )
- }
- if (s === 'error') {
- return (
- <>
- {status.error_reason || 'Your lab hit a problem.'}
-
- {busy ? 'Launching…' : 'Try Again'}
-
- >
- )
- }
- return Unknown state ({s}).
-}
diff --git a/frontend/src/platform/api.js b/frontend/src/platform/api.js
index d48be89..87884f0 100644
--- a/frontend/src/platform/api.js
+++ b/frontend/src/platform/api.js
@@ -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),
}