feat(selfpaced): student-facing My Programs dashboard
- ProgramsTab.jsx: Active/Completed programs, real progress, deterministic Continue Learning (reuses the same launch/poll pattern MyLearningTab already established), entitlement-blocked states shown with a real reason, never a silent dead end. - StudentDashboard.jsx: new My Programs tab. api.js: programs/program/launchProgram.
This commit is contained in:
@@ -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 <div className={styles.errorBox}>{error}</div>
|
||||||
|
if (!programs) return <p className={styles.muted}>Loading…</p>
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2>My Programs</h2>
|
||||||
|
<p className={styles.muted}>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.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{active.length > 0 && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2>My Programs</h2>
|
||||||
|
{active.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{completed.length > 0 && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2>Completed Programs</h2>
|
||||||
|
{completed.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cancelled.length > 0 && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2>Other</h2>
|
||||||
|
{cancelled.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
|
<strong>{program.program_name}</strong>
|
||||||
|
<span className={`${styles.pill} ${program.effective_status === 'completed' ? styles.pillOk : program.effective_status === 'expired' || program.effective_status === 'cancelled' ? styles.pillMuted : styles.pillWarn}`}>
|
||||||
|
{program.effective_status.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p && (
|
||||||
|
<div style={{ margin: '0.6rem 0' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
|
||||||
|
<span>Progress</span><span className={styles.muted}>{p.percent_complete}%</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${p.percent_complete}%` }} /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{program.effective_status === 'completed' ? (
|
||||||
|
<p className={styles.muted}>Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.</p>
|
||||||
|
) : program.blocked_reason ? (
|
||||||
|
<p className={styles.muted}>
|
||||||
|
{BLOCKED_MESSAGES[program.blocked_reason] || program.blocked_reason}
|
||||||
|
{program.blocked_reason === 'not_yet_started' && program.starts_at && ` (${new Date(program.starts_at).toLocaleDateString()})`}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{nextLabel && <p className={styles.nextUp}>Next: {nextLabel}</p>}
|
||||||
|
{error && <p className={styles.errorBox}>{error}</p>}
|
||||||
|
{envStatus && ['requested', 'provisioning'].includes(envStatus.status) ? (
|
||||||
|
<p className={styles.statusLine}><span className={styles.spinner} /> Starting your lab…</p>
|
||||||
|
) : (
|
||||||
|
<button className={styles.primaryBtn} disabled={busy} onClick={handleContinue}>
|
||||||
|
{busy ? 'Starting…' : 'Continue Learning'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@ import CapstoneTab from './CapstoneTab'
|
|||||||
import AchievementsTab from './AchievementsTab'
|
import AchievementsTab from './AchievementsTab'
|
||||||
import ClassroomTab from './ClassroomTab'
|
import ClassroomTab from './ClassroomTab'
|
||||||
import ProfileTab from './ProfileTab'
|
import ProfileTab from './ProfileTab'
|
||||||
|
import ProgramsTab from './ProgramsTab'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'learning', label: 'My Learning' },
|
{ key: 'learning', label: 'My Learning' },
|
||||||
|
{ key: 'programs', label: 'My Programs' },
|
||||||
{ key: 'classroom', label: 'Classroom' },
|
{ key: 'classroom', label: 'Classroom' },
|
||||||
{ key: 'practice', label: 'Practice' },
|
{ key: 'practice', label: 'Practice' },
|
||||||
{ key: 'assessments', label: 'Assessments' },
|
{ key: 'assessments', label: 'Assessments' },
|
||||||
@@ -45,6 +47,7 @@ export default function StudentDashboard({ student, onLogout }) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{tab === 'learning' && <MyLearningTab />}
|
{tab === 'learning' && <MyLearningTab />}
|
||||||
|
{tab === 'programs' && <ProgramsTab />}
|
||||||
{tab === 'classroom' && <ClassroomTab />}
|
{tab === 'classroom' && <ClassroomTab />}
|
||||||
{tab === 'practice' && <PracticeTab />}
|
{tab === 'practice' && <PracticeTab />}
|
||||||
{tab === 'assessments' && <AssessmentsTab />}
|
{tab === 'assessments' && <AssessmentsTab />}
|
||||||
|
|||||||
@@ -119,4 +119,9 @@ export const api = {
|
|||||||
leaveClassroom: (id) => mutate(`/api/classroom/${id}/leave`),
|
leaveClassroom: (id) => mutate(`/api/classroom/${id}/leave`),
|
||||||
classroomHeartbeat: (id) => mutate(`/api/classroom/${id}/heartbeat`),
|
classroomHeartbeat: (id) => mutate(`/api/classroom/${id}/heartbeat`),
|
||||||
requestHelp: (id, category, message) => mutate(`/api/classroom/${id}/help-request`, { category, message }),
|
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`),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user