feat(student): add learning tabs and Lab hint/assessment UX
StudentDashboard is now tabbed: My Learning (the original Phase 2A/2B flow, extracted unchanged into MyLearningTab and extended with an overall percent-complete bar), Practice, Assessments (available/active-with-live-countdown/history), Capstone (locked checklist -> unlocked -> start/resume -> completed), Achievements (a flat dated list, not a leaderboard). Inside the Lab UI: ProgressiveHints replaces the static hint array render when the Gateway has stripped hints (detected via scenario.hints being undefined but hint_count present - standalone dev mode never sees this, so it keeps the original behavior); a new top-level AssessmentBanner shows a live countdown and completion count whenever a Platform assessment is running, rendered alongside (not replacing) the pre-existing Lab-local exam-mode UI, which is untouched.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Phase 3B — shows when the student has an active Platform-level assessment (exam or capstone)
|
||||
// running, distinct from the pre-existing Lab-local exam mode (ExamTimer/ExamSession above,
|
||||
// which predates this and stays untouched). Polls /api/assessments/active, which 404s/errors
|
||||
// harmlessly in standalone/single-container dev mode (no Platform API there at all) — so this
|
||||
// component renders nothing in that mode, by design.
|
||||
export default function AssessmentBanner() {
|
||||
const [active, setActive] = useState(null)
|
||||
const [remaining, setRemaining] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
function poll() {
|
||||
fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
|
||||
if (!cancelled) setActive(d.active ? d : null)
|
||||
}).catch(() => { if (!cancelled) setActive(null) })
|
||||
}
|
||||
poll()
|
||||
const id = setInterval(poll, 15000)
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const tick = () => setRemaining(Math.max(0, new Date(active.expires_at) - Date.now()))
|
||||
tick()
|
||||
const id = setInterval(tick, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [active])
|
||||
|
||||
if (!active) return null
|
||||
const m = Math.floor(remaining / 60000), s = Math.floor((remaining % 60000) / 1000)
|
||||
const completedCount = active.scenarios.filter((sc) => sc.status === 'completed').length
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.5rem 1rem',
|
||||
background: 'var(--amber-dim)', color: 'var(--amber)', fontSize: '0.85rem', fontFamily: 'var(--mono)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<span>🎯 Assessment Mode: {active.title}</span>
|
||||
<span>{completedCount}/{active.scenarios.length} scenarios complete</span>
|
||||
<span>⏱ {m}m {s}s remaining</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import styles from './ScenarioPanel.module.css'
|
||||
|
||||
// Phase 3B progressive hints — used when the Gateway has stripped the full `hints` array from
|
||||
// the scenario payload (the multi-student, Platform-mediated deployment; see
|
||||
// platform/gateway/src/server.js's GET /lab/:token/api/scenarios/:scenarioId). Hints are
|
||||
// fetched one at a time from the Platform API, server-enforced sequential (can't skip to hint
|
||||
// 3), and every reveal is recorded against the student's attempt — none of that is possible
|
||||
// with the old fully-preloaded array this replaces for this deployment mode.
|
||||
//
|
||||
// In standalone/single-container dev mode there's no Gateway rewrite, so `scenario.hints` is
|
||||
// still the full array and ScenarioPanel never renders this component at all — see the
|
||||
// `hintCount != null` check at the call site.
|
||||
export default function ProgressiveHints({ scenarioId, totalHints }) {
|
||||
const [revealed, setRevealed] = useState([]) // [{level, title, body, command}]
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||
const [assessmentHintPolicy, setAssessmentHintPolicy] = useState(null) // null = not in an assessment; 'free'|'disabled'|'flagged'
|
||||
|
||||
// A scenario/environment change resets local reveal state — the server is still the source
|
||||
// of truth (GET /attempt would show the real highest_hint_level), this is just what's
|
||||
// rendered locally this session. Also checks whether this scenario is part of an active
|
||||
// Platform assessment, purely to show the policy indicator pre-emptively (the server enforces
|
||||
// the policy regardless, on every reveal call — this is UX, not the security boundary).
|
||||
useEffect(() => {
|
||||
setRevealed([]); setError(null); setAssessmentHintPolicy(null)
|
||||
fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
|
||||
if (d.active && d.scenarios?.some((s) => s.scenario_id === scenarioId)) setAssessmentHintPolicy(d.hint_policy)
|
||||
}).catch(() => {})
|
||||
}, [scenarioId])
|
||||
|
||||
const revealNext = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const r = await fetch(`api/scenarios/${scenarioId}/hint`, { method: 'POST' })
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setError(d.message || d.error || 'Could not reveal a hint.'); return }
|
||||
setRevealed((h) => [...h, { level: d.level, ...d.hint }])
|
||||
} catch {
|
||||
setError('Could not reach the platform to reveal a hint.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [scenarioId])
|
||||
|
||||
function copyCmd(cmd, idx) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(cmd).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1800) })
|
||||
}
|
||||
}
|
||||
|
||||
if (totalHints === 0) return <div className={styles.noHints}>No hints available for this scenario.</div>
|
||||
|
||||
if (assessmentHintPolicy === 'disabled' && revealed.length === 0) {
|
||||
return <div className={styles.noHints}>Hints Disabled — this assessment does not allow hints.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{assessmentHintPolicy === 'flagged' && <div className={styles.setupNote} style={{ marginBottom: '0.75rem' }}>Using a hint will be recorded.</div>}
|
||||
{revealed.map((hint, i) => (
|
||||
<div key={i} className={styles.hintCard}>
|
||||
<div className={styles.hintHeader}>
|
||||
<div className={styles.hintLeft}>
|
||||
<span className={styles.hintNum}>Hint {hint.level}</span>
|
||||
<span className={styles.hintTitle}>{hint.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hintBody}>
|
||||
<p className={styles.hintText}><ReactMarkdown remarkPlugins={[remarkGfm]}>{hint.body}</ReactMarkdown></p>
|
||||
{hint.command && (
|
||||
<div className={styles.cmdBlock}>
|
||||
<pre className={styles.cmdPre}>{hint.command}</pre>
|
||||
<button className={styles.copyBtn} onClick={() => copyCmd(hint.command, i)}>{copiedIdx === i ? '✓ Copied' : 'Copy'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{error && <div className={styles.validateError}>⚠ {error}</div>}
|
||||
{revealed.length < totalHints && (
|
||||
<button className={styles.setupBtn} disabled={loading} onClick={revealNext}>
|
||||
{loading ? 'Revealing…' : `Reveal Hint ${revealed.length + 1} of ${totalHints}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import styles from './ScenarioPanel.module.css'
|
||||
import { useConfirm } from './useConfirm'
|
||||
import ProgressiveHints from './ProgressiveHints'
|
||||
|
||||
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans
|
||||
const inlineComponents = {
|
||||
@@ -48,6 +49,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
setHintsRevealed([])
|
||||
}, [scenario?.id])
|
||||
|
||||
// Phase 3B attempt telemetry — fire-and-forget "scenario started" so the Platform can record
|
||||
// a real started_at for this working session. Only meaningful under the Platform-multiplied
|
||||
// deployment (routed through the Gateway); 404s harmlessly in standalone/single-container dev
|
||||
// mode, where there's no Platform API to reach at all.
|
||||
useEffect(() => {
|
||||
if (!scenario?.id) return
|
||||
fetch(`api/scenarios/${scenario.id}/start`, { method: 'POST' }).catch(() => {})
|
||||
}, [scenario?.id])
|
||||
|
||||
const timeSpentRef = useRef(0)
|
||||
const lastScenarioIdRef = useRef(null)
|
||||
|
||||
@@ -341,7 +351,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === 'problem' ? '📄 Problem'
|
||||
: t === 'hints' ? `💡 Hints (${scenario.hints?.length || 0})`
|
||||
: t === 'hints' ? `💡 Hints (${scenario.hints?.length ?? scenario.hint_count ?? 0})`
|
||||
: '✓ Validate'}
|
||||
</button>
|
||||
))}
|
||||
@@ -448,6 +458,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
{/* HINTS TAB */}
|
||||
{tab === 'hints' && (
|
||||
<div className={styles.tabPane} style={{ animation: 'fadeIn 0.2s ease' }}>
|
||||
{/* Phase 3B: the Gateway strips `hints` from the payload under the Platform-
|
||||
multiplied deployment (see platform/gateway/src/server.js) — hint_count survives
|
||||
as the signal to switch to server-gated progressive reveal instead of the old
|
||||
fully-preloaded array. Standalone/dev mode still has the full array (no Gateway
|
||||
rewrite happens there) and keeps the original behavior untouched below. */}
|
||||
{scenario.hints === undefined && typeof scenario.hint_count === 'number' ? (
|
||||
<ProgressiveHints scenarioId={scenario.id} totalHints={scenario.hint_count} />
|
||||
) : (
|
||||
<>
|
||||
{scenario.hints?.length === 0 && (
|
||||
<div className={styles.noHints}>No hints available for this scenario.</div>
|
||||
)}
|
||||
@@ -483,6 +502,8 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user