diff --git a/frontend/src/platform/ProgramsTab.jsx b/frontend/src/platform/ProgramsTab.jsx
new file mode 100644
index 0000000..cdf6246
--- /dev/null
+++ b/frontend/src/platform/ProgramsTab.jsx
@@ -0,0 +1,140 @@
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { api } from './api'
+import styles from './Platform.module.css'
+
+const POLL_MS = 2500
+
+const BLOCKED_MESSAGES = {
+ pending_activation: 'Your enrollment is pending — an instructor still needs to activate it.',
+ not_yet_started: 'This program opens on the date below. Check back then.',
+ access_expired: 'Access to this program has ended. Your progress is still here for reference.',
+ cancelled: 'This enrollment was cancelled.',
+}
+
+// Phase 5B §6 — "My Programs." Every enrollment the student holds (GET /api/student/programs
+// already carries real access/progress/next-activity per enrollment — see program-status.js),
+// split into Active and Completed. Continue Learning delegates to the entitlement-gated
+// POST /api/student/programs/:id/launch, which reuses the exact same disposable-Lab sequence
+// the My Learning tab's Launch Lab button does — this is not a second kind of Lab.
+export default function ProgramsTab() {
+ const [programs, setPrograms] = useState(null)
+ const [error, setError] = useState(null)
+
+ const load = useCallback(() => {
+ api.programs().then(setPrograms).catch((e) => setError(e.message))
+ }, [])
+ useEffect(load, [load])
+
+ if (error) return
{error}
+ if (!programs) return Loading…
+
+ const active = programs.filter((p) => ['pending', 'active', 'expired'].includes(p.effective_status))
+ const completed = programs.filter((p) => p.effective_status === 'completed')
+ const cancelled = programs.filter((p) => p.effective_status === 'cancelled')
+
+ if (!programs.length) {
+ return (
+
+
My Programs
+
You're not enrolled in any program yet. Talk to your instructor, or check for an enrollment email if you signed up through the website.
+
+ )
+ }
+
+ return (
+
+ {active.length > 0 && (
+
+
My Programs
+ {active.map((p) =>
)}
+
+ )}
+ {completed.length > 0 && (
+
+
Completed Programs
+ {completed.map((p) =>
)}
+
+ )}
+ {cancelled.length > 0 && (
+
+
Other
+ {cancelled.map((p) =>
)}
+
+ )}
+
+ )
+}
+
+function ProgramCard({ program, onChange }) {
+ const [envStatus, setEnvStatus] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+ const pollRef = useRef(null)
+
+ useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
+
+ const poll = useCallback(() => {
+ api.environmentStatus().then((s) => {
+ setEnvStatus(s)
+ if (!['requested', 'provisioning', 'resetting'].includes(s.status) && pollRef.current) {
+ clearInterval(pollRef.current); pollRef.current = null
+ }
+ }).catch(() => {})
+ }, [])
+
+ const handleContinue = async () => {
+ setBusy(true); setError(null)
+ try {
+ const result = await api.launchProgram(program.enrollment_id)
+ setEnvStatus(result)
+ if (result.status === 'running' && result.launch_path) { window.location.href = result.launch_path; return }
+ if (['requested', 'provisioning'].includes(result.status) && !pollRef.current) {
+ pollRef.current = setInterval(poll, POLL_MS)
+ }
+ } catch (e) { setError(e.message) } finally { setBusy(false) }
+ }
+
+ const p = program.progress
+ const nextLabel = program.next_activity?.type === 'capstone' ? 'RootNode Capstone' : program.next_activity?.scenario_id
+
+ return (
+
+
+ {program.program_name}
+
+ {program.effective_status.replace(/_/g, ' ')}
+
+
+
+ {p && (
+
+
+ Progress {p.percent_complete}%
+
+
+
+ )}
+
+ {program.effective_status === 'completed' ? (
+
Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.
+ ) : program.blocked_reason ? (
+
+ {BLOCKED_MESSAGES[program.blocked_reason] || program.blocked_reason}
+ {program.blocked_reason === 'not_yet_started' && program.starts_at && ` (${new Date(program.starts_at).toLocaleDateString()})`}
+
+ ) : (
+ <>
+ {nextLabel &&
Next: {nextLabel}
}
+ {error &&
{error}
}
+ {envStatus && ['requested', 'provisioning'].includes(envStatus.status) ? (
+
Starting your lab…
+ ) : (
+
+ {busy ? 'Starting…' : 'Continue Learning'}
+
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/frontend/src/platform/StudentDashboard.jsx b/frontend/src/platform/StudentDashboard.jsx
index cb09f54..446ab60 100644
--- a/frontend/src/platform/StudentDashboard.jsx
+++ b/frontend/src/platform/StudentDashboard.jsx
@@ -8,9 +8,11 @@ import CapstoneTab from './CapstoneTab'
import AchievementsTab from './AchievementsTab'
import ClassroomTab from './ClassroomTab'
import ProfileTab from './ProfileTab'
+import ProgramsTab from './ProgramsTab'
const TABS = [
{ key: 'learning', label: 'My Learning' },
+ { key: 'programs', label: 'My Programs' },
{ key: 'classroom', label: 'Classroom' },
{ key: 'practice', label: 'Practice' },
{ key: 'assessments', label: 'Assessments' },
@@ -45,6 +47,7 @@ export default function StudentDashboard({ student, onLogout }) {
{tab === 'learning' && }
+ {tab === 'programs' && }
{tab === 'classroom' && }
{tab === 'practice' && }
{tab === 'assessments' && }
diff --git a/frontend/src/platform/api.js b/frontend/src/platform/api.js
index 5f6bfb9..66016a9 100644
--- a/frontend/src/platform/api.js
+++ b/frontend/src/platform/api.js
@@ -119,4 +119,9 @@ export const api = {
leaveClassroom: (id) => mutate(`/api/classroom/${id}/leave`),
classroomHeartbeat: (id) => mutate(`/api/classroom/${id}/heartbeat`),
requestHelp: (id, category, message) => mutate(`/api/classroom/${id}/help-request`, { category, message }),
+
+ // ── Phase 5B: self-paced programs ────────────────────────────────────────────────────────
+ programs: () => fetch('/api/student/programs').then(json),
+ program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json),
+ launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`),
}