From 8c09ef8f3e5d46d6f6ff6193285f1ba19aa266be Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 25 Aug 2026 23:29:41 -0500 Subject: [PATCH] feat(student): add structured course learning experience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/platform/CourseView.jsx | 214 ++++++++++++++++++++++++++ frontend/src/platform/ProgramsTab.jsx | 29 +++- frontend/src/platform/api.js | 4 + 3 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 frontend/src/platform/CourseView.jsx diff --git a/frontend/src/platform/CourseView.jsx b/frontend/src/platform/CourseView.jsx new file mode 100644 index 0000000..6530875 --- /dev/null +++ b/frontend/src/platform/CourseView.jsx @@ -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
{error}
+ if (!data) return

Loading…

+ + 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) => + + if (activity) { + return ( +
+
+ {crumb('← All courses', () => { setOpenCourse(null); setOpenModule(null); setOpenActivity(null) })} + {crumb(course.title, () => setOpenActivity(null))} +
+ +
+ ) + } + + if (mod) { + return ( +
+
+ {crumb('← All courses', () => { setOpenCourse(null); setOpenModule(null) })} + {crumb(course.title, () => setOpenModule(null))} +
+

{mod.title}

+ {mod.description &&

{mod.description}

} + +
+ {mod.activities.map((a) => ( + setOpenActivity(a.activity_id)} /> + ))} +
+
+ ) + } + + if (course) { + return ( +
+
+ {crumb('← All courses', () => setOpenCourse(null))} +
+

{course.title}

+ {course.description &&

{course.description}

} + {course.objectives?.length > 0 && ( +
    + {course.objectives.map((o, i) =>
  • {o}
  • )} +
+ )} +
+ {course.modules.length === 0 ? ( +

No modules published for this course yet.

+ ) : course.modules.map((m) => ( + !m.locked && setOpenModule(m.module_id)} /> + ))} +
+
+ ) + } + + return ( +
+
+ {onClose && crumb('← Back to program', onClose)} +
+

ROOT_ Courses

+ {data.resume && ( +

+ Resume: {data.courses.find((c) => c.course_id === data.resume.course_id)?.title} +

+ )} + {data.courses.map((c) => ( + !c.locked && setOpenCourse(c.course_id)} /> + ))} +
+ ) +} + +function statusPill(status) { + const cls = status === 'ready' ? styles.pillOk : status === 'partial' ? styles.pillWarn : styles.pillMuted + return {status} +} + +function CourseRow({ course, onOpen }) { + return ( +
+
+ +
+ {statusPill(course.status)} + {course.locked && locked} +
+
+ {!course.locked && course.modules.length > 0 && ( +
+
+
+ )} +
+ ) +} + +function ModuleRow({ mod, onOpen }) { + return ( +
+ +
+ {mod.complete ? complete : statusPill(mod.status)} + {mod.locked && locked} +
+
+ ) +} + +function ModuleMeta({ mod }) { + if (!mod.objectives?.length && !mod.estimated_duration_minutes) return null + return ( +
+ {mod.estimated_duration_minutes &&
Estimated time: ~{mod.estimated_duration_minutes} min
} + {mod.objectives?.length > 0 && ( +
    {mod.objectives.map((o, i) =>
  • {o}
  • )}
+ )} +
+ ) +} + +function ActivityRow({ activity, onOpen }) { + return ( +
+ + {activity.complete ? done : activity.estimated_duration_minutes ? ~{activity.estimated_duration_minutes} min : null} +
+ ) +} + +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 ( +
+
+

{activity.title}

+ {activity.complete && complete} +
+

{ACTIVITY_LABELS[activity.activity_type] || activity.activity_type} + {activity.estimated_duration_minutes && ` · ~${activity.estimated_duration_minutes} min`}

+ + {activity.body ? ( +
+ {activity.body} +
+ ) : ( +

No content authored for this activity yet.

+ )} + + {error &&

{error}

} + +
+ {activity.activity_type === 'lab' && !activity.complete && ( + + )} + {activity.completion_behavior === 'manual_marker' && !activity.complete && ( + + )} + {activity.activity_type === 'capstone' && ( +

Continue via the Capstone tab to start or resume this assessment.

+ )} +
+
+ ) +} diff --git a/frontend/src/platform/ProgramsTab.jsx b/frontend/src/platform/ProgramsTab.jsx index cdf6246..aed3fc7 100644 --- a/frontend/src/platform/ProgramsTab.jsx +++ b/frontend/src/platform/ProgramsTab.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { api } from './api' import styles from './Platform.module.css' +import CourseView from './CourseView' const POLL_MS = 2500 @@ -69,6 +70,7 @@ function ProgramCard({ program, onChange }) { const [envStatus, setEnvStatus] = useState(null) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + const [showCourses, setShowCourses] = useState(false) const pollRef = useRef(null) useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, []) @@ -97,6 +99,19 @@ function ProgramCard({ program, onChange }) { const p = program.progress 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 ( +
+ setShowCourses(false)} /> +
+ ) + } + return (
@@ -116,7 +131,10 @@ function ProgramCard({ program, onChange }) { )} {program.effective_status === 'completed' ? ( -

Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.

+ <> +

Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.

+ + ) : 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) ? (

Starting your lab…

) : ( - +
+ + +
)} )} diff --git a/frontend/src/platform/api.js b/frontend/src/platform/api.js index 66016a9..642e9fe 100644 --- a/frontend/src/platform/api.js +++ b/frontend/src/platform/api.js @@ -124,4 +124,8 @@ export const api = { programs: () => fetch('/api/student/programs').then(json), program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json), 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`), }