feat(student): add structured course learning experience

CourseView.jsx — My Programs -> ROOT_ -> Course -> Module -> Activity ->
Launch Lab -> back -> continue, nested inside the existing self-paced
ProgramsTab rather than a second portal. Renders lesson/demonstration/
knowledge-check/guided-exercise/reflection content via the existing
react-markdown+remark-gfm stack (already used by ScenarioPanel.jsx — no
new dependency). Locked modules/courses are inert, not hidden — the
server is still the actual gate (course-routes.js).

ProgramsTab.jsx gains a 'View Courses' button alongside the existing
'Continue Learning' button — the original direct-to-Lab flow is
byte-for-byte unchanged; both paths call the identical launch sequence
underneath.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-25 23:29:41 -05:00
co-authored by Claude Sonnet 5
parent 28c925069b
commit 8c09ef8f3e
3 changed files with 243 additions and 4 deletions
+214
View File
@@ -0,0 +1,214 @@
import { useState, useEffect, useCallback } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { api } from './api'
import styles from './Platform.module.css'
const ACTIVITY_LABELS = {
lesson: 'Lesson', demonstration: 'Demonstration', knowledge_check: 'Knowledge Check',
guided_exercise: 'Guided Exercise', lab: 'Lab', reflection: 'Reflection',
assessment: 'Assessment', capstone: 'Capstone',
}
// Phase 6A §6 — structured course learning, nested inside "My Programs" (not a second portal).
// Local drill-down state only (course -> module -> activity), same "no router" convention as
// the rest of the Platform Shell (see PlatformShell.jsx). Separate from PracticeTab — this is
// the guided course path, Practice stays the free-form scenario list it always was.
export default function CourseView({ enrollmentId, onLaunchLab, onClose }) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [openCourse, setOpenCourse] = useState(null)
const [openModule, setOpenModule] = useState(null)
const [openActivity, setOpenActivity] = useState(null)
const load = useCallback(() => {
api.courses(enrollmentId).then(setData).catch((e) => setError(e.message))
}, [enrollmentId])
useEffect(load, [load])
if (error) return <div className={styles.errorBox}>{error}</div>
if (!data) return <p className={styles.muted}>Loading…</p>
const course = openCourse && data.courses.find((c) => c.course_id === openCourse)
const mod = course && openModule && course.modules.find((m) => m.module_id === openModule)
const activity = mod && openActivity && mod.activities.find((a) => a.activity_id === openActivity)
const crumb = (label, onClick) => <button key={label} className={styles.linkBtn} onClick={onClick}>{label}</button>
if (activity) {
return (
<div className={styles.card}>
<div className={styles.btnRow} style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
{crumb('← All courses', () => { setOpenCourse(null); setOpenModule(null); setOpenActivity(null) })}
{crumb(course.title, () => setOpenActivity(null))}
</div>
<ActivityDetail activity={activity} onLaunchLab={onLaunchLab} onChange={load} />
</div>
)
}
if (mod) {
return (
<div className={styles.card}>
<div className={styles.btnRow} style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
{crumb('← All courses', () => { setOpenCourse(null); setOpenModule(null) })}
{crumb(course.title, () => setOpenModule(null))}
</div>
<h2>{mod.title}</h2>
{mod.description && <p className={styles.muted}>{mod.description}</p>}
<ModuleMeta mod={mod} />
<div style={{ marginTop: '1rem' }}>
{mod.activities.map((a) => (
<ActivityRow key={a.activity_id} activity={a} onOpen={() => setOpenActivity(a.activity_id)} />
))}
</div>
</div>
)
}
if (course) {
return (
<div className={styles.card}>
<div className={styles.btnRow} style={{ marginBottom: '1rem' }}>
{crumb('← All courses', () => setOpenCourse(null))}
</div>
<h2>{course.title}</h2>
{course.description && <p className={styles.muted}>{course.description}</p>}
{course.objectives?.length > 0 && (
<ul style={{ marginTop: '0.5rem' }}>
{course.objectives.map((o, i) => <li key={i} className={styles.checklistItem}>{o}</li>)}
</ul>
)}
<div style={{ marginTop: '1rem' }}>
{course.modules.length === 0 ? (
<p className={styles.muted}>No modules published for this course yet.</p>
) : course.modules.map((m) => (
<ModuleRow key={m.module_id} mod={m} onOpen={() => !m.locked && setOpenModule(m.module_id)} />
))}
</div>
</div>
)
}
return (
<div className={styles.card}>
<div className={styles.btnRow} style={{ marginBottom: '1rem' }}>
{onClose && crumb('← Back to program', onClose)}
</div>
<h2>ROOT_ Courses</h2>
{data.resume && (
<p className={styles.nextUp}>
Resume: {data.courses.find((c) => c.course_id === data.resume.course_id)?.title}
</p>
)}
{data.courses.map((c) => (
<CourseRow key={c.course_id} course={c} onOpen={() => !c.locked && setOpenCourse(c.course_id)} />
))}
</div>
)
}
function statusPill(status) {
const cls = status === 'ready' ? styles.pillOk : status === 'partial' ? styles.pillWarn : styles.pillMuted
return <span className={`${styles.pill} ${cls}`}>{status}</span>
}
function CourseRow({ course, onOpen }) {
return (
<div style={{ borderTop: '1px solid var(--border)', padding: '0.75rem 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<button className={styles.linkBtn} disabled={course.locked} onClick={onOpen} style={{ fontWeight: 600, textAlign: 'left' }}>
{course.title}
</button>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{statusPill(course.status)}
{course.locked && <span className={`${styles.pill} ${styles.pillMuted}`}>locked</span>}
</div>
</div>
{!course.locked && course.modules.length > 0 && (
<div style={{ marginTop: '0.4rem' }}>
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${course.percent_complete}%` }} /></div>
</div>
)}
</div>
)
}
function ModuleRow({ mod, onOpen }) {
return (
<div style={{ borderTop: '1px solid var(--border)', padding: '0.6rem 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<button className={styles.linkBtn} disabled={mod.locked} onClick={onOpen} style={{ textAlign: 'left' }}>{mod.title}</button>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{mod.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>complete</span> : statusPill(mod.status)}
{mod.locked && <span className={`${styles.pill} ${styles.pillMuted}`}>locked</span>}
</div>
</div>
)
}
function ModuleMeta({ mod }) {
if (!mod.objectives?.length && !mod.estimated_duration_minutes) return null
return (
<div className={styles.hint} style={{ marginTop: '0.5rem' }}>
{mod.estimated_duration_minutes && <div>Estimated time: ~{mod.estimated_duration_minutes} min</div>}
{mod.objectives?.length > 0 && (
<ul style={{ marginTop: '0.3rem' }}>{mod.objectives.map((o, i) => <li key={i}>{o}</li>)}</ul>
)}
</div>
)
}
function ActivityRow({ activity, onOpen }) {
return (
<div style={{ borderTop: '1px solid var(--border)', padding: '0.6rem 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<button className={styles.linkBtn} onClick={onOpen} style={{ textAlign: 'left' }}>
<span className={styles.stageCount}>{ACTIVITY_LABELS[activity.activity_type] || activity.activity_type}</span>{' — '}{activity.title}
</button>
{activity.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>done</span> : activity.estimated_duration_minutes ? <span className={styles.hint}>~{activity.estimated_duration_minutes} min</span> : null}
</div>
)
}
function ActivityDetail({ activity, onLaunchLab, onChange }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const markComplete = async () => {
setBusy(true); setError(null)
try { await api.completeActivity(activity.activity_id); onChange() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h2>{activity.title}</h2>
{activity.complete && <span className={`${styles.pill} ${styles.pillOk}`}>complete</span>}
</div>
<p className={styles.stageCount}>{ACTIVITY_LABELS[activity.activity_type] || activity.activity_type}
{activity.estimated_duration_minutes && ` · ~${activity.estimated_duration_minutes} min`}</p>
{activity.body ? (
<div className="md" style={{ marginTop: '1rem' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{activity.body}</ReactMarkdown>
</div>
) : (
<p className={styles.muted} style={{ marginTop: '1rem' }}>No content authored for this activity yet.</p>
)}
{error && <p className={styles.errorBox}>{error}</p>}
<div style={{ marginTop: '1.5rem' }}>
{activity.activity_type === 'lab' && !activity.complete && (
<button className={styles.primaryBtn} onClick={() => onLaunchLab?.(activity.scenario_id)}>Launch Lab</button>
)}
{activity.completion_behavior === 'manual_marker' && !activity.complete && (
<button className={styles.primaryBtn} disabled={busy} onClick={markComplete}>{busy ? 'Saving…' : 'Mark Complete'}</button>
)}
{activity.activity_type === 'capstone' && (
<p className={styles.muted}>Continue via the Capstone tab to start or resume this assessment.</p>
)}
</div>
</div>
)
}
+25 -4
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { api } from './api' import { api } from './api'
import styles from './Platform.module.css' import styles from './Platform.module.css'
import CourseView from './CourseView'
const POLL_MS = 2500 const POLL_MS = 2500
@@ -69,6 +70,7 @@ function ProgramCard({ program, onChange }) {
const [envStatus, setEnvStatus] = useState(null) const [envStatus, setEnvStatus] = useState(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [showCourses, setShowCourses] = useState(false)
const pollRef = useRef(null) const pollRef = useRef(null)
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, []) useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
@@ -97,6 +99,19 @@ function ProgramCard({ program, onChange }) {
const p = program.progress const p = program.progress
const nextLabel = program.next_activity?.type === 'capstone' ? 'RootNode Capstone' : program.next_activity?.scenario_id const nextLabel = program.next_activity?.type === 'capstone' ? 'RootNode Capstone' : program.next_activity?.scenario_id
// Phase 6A §6 — "View Courses" opens the structured course/module/activity tree for this
// enrollment, right here inline (My Programs -> ROOT_ -> Course -> Module -> Activity), no
// separate portal. "Continue Learning" keeps its original Phase 5B behavior unchanged
// (straight to the disposable Lab) — CourseView's own Launch Lab buttons call the exact same
// handleContinue, so there is still only one launch path underneath both entry points.
if (showCourses) {
return (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
<CourseView enrollmentId={program.enrollment_id} onLaunchLab={handleContinue} onClose={() => setShowCourses(false)} />
</div>
)
}
return ( return (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}> <div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
@@ -116,7 +131,10 @@ function ProgramCard({ program, onChange }) {
)} )}
{program.effective_status === 'completed' ? ( {program.effective_status === 'completed' ? (
<p className={styles.muted}>Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.</p> <>
<p className={styles.muted}>Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.</p>
<button className={styles.secondaryBtn} onClick={() => setShowCourses(true)}>View Courses</button>
</>
) : program.blocked_reason ? ( ) : program.blocked_reason ? (
<p className={styles.muted}> <p className={styles.muted}>
{BLOCKED_MESSAGES[program.blocked_reason] || program.blocked_reason} {BLOCKED_MESSAGES[program.blocked_reason] || program.blocked_reason}
@@ -129,9 +147,12 @@ function ProgramCard({ program, onChange }) {
{envStatus && ['requested', 'provisioning'].includes(envStatus.status) ? ( {envStatus && ['requested', 'provisioning'].includes(envStatus.status) ? (
<p className={styles.statusLine}><span className={styles.spinner} /> Starting your lab…</p> <p className={styles.statusLine}><span className={styles.spinner} /> Starting your lab…</p>
) : ( ) : (
<button className={styles.primaryBtn} disabled={busy} onClick={handleContinue}> <div className={styles.btnRow}>
{busy ? 'Starting…' : 'Continue Learning'} <button className={styles.primaryBtn} disabled={busy} onClick={handleContinue}>
</button> {busy ? 'Starting…' : 'Continue Learning'}
</button>
<button className={styles.secondaryBtn} onClick={() => setShowCourses(true)}>View Courses</button>
</div>
)} )}
</> </>
)} )}
+4
View File
@@ -124,4 +124,8 @@ export const api = {
programs: () => fetch('/api/student/programs').then(json), programs: () => fetch('/api/student/programs').then(json),
program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json), program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json),
launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`), launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`),
// ── Phase 6A: course/module/activity content ─────────────────────────────────────────────
courses: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}/courses`).then(json),
completeActivity: (activityId) => mutate(`/api/student/activities/${activityId}/complete`),
} }