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…

+ ) : ( + + )} + + )} +
+ ) +} 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`), }