From d4db8f99f5ef128dfb1aa38fbc19b69ba4460bb2 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 18 Aug 2026 14:35:59 -0500 Subject: [PATCH] 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. --- frontend/src/App.jsx | 2 + frontend/src/components/AssessmentBanner.jsx | 47 ++++++ frontend/src/components/ProgressiveHints.jsx | 91 ++++++++++ frontend/src/components/ScenarioPanel.jsx | 23 ++- frontend/src/platform/AchievementsTab.jsx | 40 +++++ frontend/src/platform/AssessmentsTab.jsx | 113 +++++++++++++ frontend/src/platform/CapstoneTab.jsx | 89 ++++++++++ frontend/src/platform/MyLearningTab.jsx | 135 +++++++++++++++ frontend/src/platform/Platform.module.css | 22 +++ frontend/src/platform/PracticeTab.jsx | 77 +++++++++ frontend/src/platform/StudentDashboard.jsx | 168 ++++--------------- frontend/src/platform/api.js | 13 ++ 12 files changed, 680 insertions(+), 140 deletions(-) create mode 100644 frontend/src/components/AssessmentBanner.jsx create mode 100644 frontend/src/components/ProgressiveHints.jsx create mode 100644 frontend/src/platform/AchievementsTab.jsx create mode 100644 frontend/src/platform/AssessmentsTab.jsx create mode 100644 frontend/src/platform/CapstoneTab.jsx create mode 100644 frontend/src/platform/MyLearningTab.jsx create mode 100644 frontend/src/platform/PracticeTab.jsx 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}
+ +
+ )} +
+
+ ))} + {error &&
⚠ {error}
} + {revealed.length < 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}

+ + + + + {active.scenarios.map((s) => ( + + + + + ))} + +
#ScenarioStatus
{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 && ( + + )} +
+ ) + }) + )} +
+ +
+

History

+ {!assessments ?

Loading…

: ( + + + + {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) => ( + + + + + + + ))} + +
AssessmentStartedStatusResult
{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.

+ ) : ( + + )} + + {capstone.sessions.length > 0 && ( + <> +

Attempt History

+ + + + {capstone.sessions.map((s) => ( + + + + + + ))} + +
StartedStatusResult
{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.

+ )} + + )} +
+ +
+

Your Lab

+ +
+ + ) +} + +function EnvironmentPanel({ status, busy, onLaunch, onReset }) { + const s = status?.status || 'none' + + if (s === 'none' || s === 'destroyed') { + return ( + <> +

No lab running yet.

+ + + ) + } + if (['requested', 'provisioning', 'resetting'].includes(s)) { + return

{s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}

+ } + if (s === 'running') { + return ( + <> +

Lab ready.

+
+ Continue Lab + +
+

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.'}

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

: ( + + + + {curriculum.stages.map((s) => ( + + + + + + ))} + +
StageCompletedStatus
{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.

: ( + + + + {attempts.map((a, i) => ( + + + + + + + + ))} + +
ScenarioStatusValidationsHintsDuration
{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}
} + -
-

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.

} - - )} -
- -
-

Your Lab

- -
+ {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.

- - - ) - } - if (['requested', 'provisioning', 'resetting'].includes(s)) { - return

{s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}

- } - if (s === 'running') { - return ( - <> -

Lab ready.

-
- Continue Lab - -
-

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.'}

- - - ) - } - 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), }