refactor(exam): flat sidebar list, weight badges, UI cleanup

Signed-off-by: Abhinav Sinha <[email protected]>
This commit is contained in:
Abhinav Sinha
2026-06-18 01:11:19 +05:30
parent 7e75c8ae02
commit 09bd1b20da
9 changed files with 273 additions and 72 deletions
+24 -9
View File
@@ -103,7 +103,10 @@ export default function App() {
setLoading(true)
setActiveId(null)
setScenario(null)
const url = `/api/scenarios?bundle=${activeBundleId}`
// If an exam session is active, fetch only that session's scenarios (pre-shuffled)
const url = examSession?.id
? `/api/scenarios?session=${examSession.id}`
: `/api/scenarios?bundle=${activeBundleId}`
fetch(url)
.then(r => r.json())
.then(data => {
@@ -112,7 +115,7 @@ export default function App() {
})
.catch(console.error)
.finally(() => setLoading(false))
}, [activeBundleId])
}, [activeBundleId, examSession?.id])
// ── Load full scenario when selected — teardown previous, load new ─────────
useEffect(() => {
@@ -137,9 +140,12 @@ export default function App() {
}, [activeId])
const refreshProgress = useCallback(async () => {
const scenarioUrl = examSession?.id
? `/api/scenarios?session=${examSession.id}`
: `/api/scenarios?bundle=${activeBundleId}`
const [bundleData, scenarioData] = await Promise.all([
fetch('/api/bundles').then(r => r.json()),
fetch(`/api/scenarios?bundle=${activeBundleId}`).then(r => r.json()),
fetch(scenarioUrl).then(r => r.json()),
])
setBundles(bundleData)
setScenarios(scenarioData)
@@ -160,13 +166,13 @@ export default function App() {
}, [activeBundleId, activeId, examSession])
// ── Exam actions ──────────────────────────────────────────────────────────
const startExam = useCallback(async (bundleId, customMinutes) => {
const startExam = useCallback(async (bundleId, customMinutes, customScenarioCount) => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bundleId, examMinutes: customMinutes }),
body: JSON.stringify({ bundleId, examMinutes: customMinutes, scenarioCount: customScenarioCount }),
}).then(r => r.json())
// refresh to get scenarioCount
// refresh to get scenario_ids, scenarioCount
const active = await fetch('/api/sessions/active').then(r => r.json())
setExamSession(active)
setActiveBundleId(bundleId)
@@ -257,6 +263,13 @@ export default function App() {
const activeBundle = bundles.find(b => b.id === activeBundleId) || null
const isMcq = scenario?.type === 'mcq'
// In exam mode: scenarios are already pre-filtered and ordered by the session API
const examScenarios = scenarios
// Total weight of the exam's selected scenarios (for % display in ScenarioPanel)
const totalExamWeight = (!!examSession ? examScenarios : scenarios)
.reduce((sum, s) => sum + (s.weight || 0), 0)
return (
<div className={styles.app}>
<Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} />
@@ -290,7 +303,7 @@ export default function App() {
<div className={styles.body}>
{/* Sidebar */}
<Sidebar
scenarios={scenarios}
scenarios={!!examSession ? examScenarios : scenarios}
activeId={activeId}
onSelect={setActiveId}
loading={loading}
@@ -301,6 +314,7 @@ export default function App() {
onProgressUpdate={refreshProgress}
isExamMode={!!examSession}
examProgress={examProgress}
totalExamWeight={totalExamWeight}
/>
{/* Sidebar resize handle */}
@@ -315,6 +329,7 @@ export default function App() {
onScenarioStart={handleScenarioStart}
isExamMode={!!examSession}
examProgress={examProgress}
totalExamWeight={totalExamWeight}
/>
</div>
@@ -350,9 +365,9 @@ export default function App() {
{examModalBundle && (
<ExamStartModal
bundle={examModalBundle}
onStart={mins => {
onStart={(mins, count) => {
setExamModalBundle(null)
startExam(examModalBundle.id, mins)
startExam(examModalBundle.id, mins, count)
}}
onCancel={() => setExamModalBundle(null)}
/>
+49 -5
View File
@@ -2,11 +2,18 @@ import { useState, useEffect, useRef } from 'react'
import styles from './ExamStartModal.module.css'
export default function ExamStartModal({ bundle, onStart, onCancel }) {
const totalScenarios = bundle?.scenario_ids?.length || 0
const [minutes, setMinutes] = useState(bundle?.exam_minutes || 120)
const [scenarioCount, setScenarioCount] = useState(totalScenarios)
const inputRef = useRef(null)
// Keep scenarioCount in sync if bundle changes
useEffect(() => {
// Focus input on open
setScenarioCount(bundle?.scenario_ids?.length || 0)
}, [bundle])
useEffect(() => {
// Focus duration input on open
const t = setTimeout(() => inputRef.current?.select(), 60)
return () => clearTimeout(t)
}, [])
@@ -14,11 +21,14 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
if (!bundle) return null
const numMinutes = Number(minutes)
const isValid = numMinutes >= 5 && numMinutes <= 300
const numScenarios = Number(scenarioCount)
const isMinutesValid = numMinutes >= 5 && numMinutes <= 300
const isScenariosValid = numScenarios >= 1 && numScenarios <= totalScenarios
const isValid = isMinutesValid && isScenariosValid
const handleStart = () => {
if (!isValid) return
onStart(numMinutes)
onStart(numMinutes, numScenarios)
}
const presets = [
@@ -42,9 +52,10 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
<div className={styles.body}>
<div className={styles.info}>
<span>📋</span>
<span>{bundle.scenario_ids?.length || '?'} scenarios · Recommended: <strong>{bundle.exam_minutes} min</strong></span>
<span>{totalScenarios} scenarios · Recommended: <strong>{bundle.exam_minutes} min</strong></span>
</div>
{/* Duration field */}
<div className={styles.field}>
<label className={styles.label}>Exam Duration</label>
<div className={styles.presets}>
@@ -73,13 +84,46 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
<span className={styles.unit}>minutes</span>
</div>
<div className={styles.hint}>
{!isValid ? (
{!isMinutesValid ? (
<span style={{ color: 'var(--red)' }}> Duration must be between 5 and 300 minutes</span>
) : numMinutes < 60 ? '⚡ Speed run mode' :
numMinutes <= 120 ? '🎯 Realistic exam timing' :
'🧘 Relaxed practice pace'}
</div>
</div>
{/* Scenario count field */}
<div className={styles.field}>
<label className={styles.label}>Number of Scenarios</label>
<div className={styles.scenarioRow}>
<input
type="range"
className={styles.slider}
value={numScenarios}
min={1}
max={totalScenarios}
step={1}
onChange={e => setScenarioCount(Number(e.target.value))}
style={{ '--bcolor': bundle.color }}
/>
<input
type="number"
className={styles.input}
value={scenarioCount}
min={1}
max={totalScenarios}
onChange={e => setScenarioCount(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleStart()}
/>
</div>
<div className={styles.hint}>
{!isScenariosValid ? (
<span style={{ color: 'var(--red)' }}> Must be between 1 and {totalScenarios}</span>
) : numScenarios === totalScenarios
? `📚 Full exam — all ${totalScenarios} scenarios`
: `🎯 ${numScenarios} randomly selected scenario${numScenarios > 1 ? 's' : ''}`}
</div>
</div>
</div>
<div className={styles.actions}>
@@ -116,6 +116,24 @@
gap: 8px;
}
.scenarioRow {
display: flex;
align-items: center;
gap: 10px;
}
.slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 2px;
background: var(--border2);
outline: none;
cursor: pointer;
accent-color: var(--bcolor, var(--blue));
}
.input {
width: 90px;
padding: 8px 12px;
+6 -1
View File
@@ -24,7 +24,7 @@ async function resetProgress(scope, opts) {
})
}
export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode, examProgress }) {
export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode, examProgress, totalExamWeight }) {
const [tab, setTab] = useState('problem')
const [setupState, setSetupState] = useState('idle') // idle | running | done | error
const [validating, setValidating] = useState(false)
@@ -208,6 +208,11 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
)}
<span className={styles.typeTag}>{scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'}</span>
{!isExamMode && <span className={styles.weight}>{scenario.weight} pts</span>}
{isExamMode && totalExamWeight > 0 && (
<span className={styles.examWeight}>
{Math.round((scenario.weight / totalExamWeight) * 100)}% weight
</span>
)}
</div>
<div className={styles.titleRow}>
<div className={styles.scenarioTitle}>{scenario.title}</div>
@@ -76,6 +76,19 @@
margin-left: auto;
}
.examWeight {
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
color: var(--teal);
background: var(--teal-dim);
padding: 2px 7px;
border-radius: 3px;
border: 1px solid color-mix(in srgb, var(--teal) 30%, transparent);
margin-left: auto;
}
.scenarioTitle {
font-size: 19px;
font-weight: 800;
+58 -36
View File
@@ -16,7 +16,7 @@ export default function Sidebar({
scenarios, activeId, onSelect, loading,
collapsed, onToggleCollapse, width,
activeBundleId, onProgressUpdate,
isExamMode, examProgress,
isExamMode, examProgress, totalExamWeight,
}) {
const [filterDiff, setFilterDiff] = useState('All')
const [filterType, setFilterType] = useState('All')
@@ -106,21 +106,19 @@ export default function Sidebar({
</button>
</div>
{/* Filter bar */}
{!collapsed && (
{/* Filter bar — hidden in exam mode */}
{!collapsed && !isExamMode && (
<div className={styles.filterBar}>
{!isExamMode && (
<select
value={filterDiff}
onChange={e => setFilterDiff(e.target.value)}
className={`${styles.selectFilter} ${filterDiff !== 'All' ? styles[DIFF_COLOR[filterDiff]] : ''}`}
>
<option value="All">All Difficulties</option>
<option value="Easy">Easy</option>
<option value="Medium">Medium</option>
<option value="Hard">Hard</option>
</select>
)}
<select
value={filterDiff}
onChange={e => setFilterDiff(e.target.value)}
className={`${styles.selectFilter} ${filterDiff !== 'All' ? styles[DIFF_COLOR[filterDiff]] : ''}`}
>
<option value="All">All Difficulties</option>
<option value="Easy">Easy</option>
<option value="Medium">Medium</option>
<option value="Hard">Hard</option>
</select>
<select
value={filterType}
onChange={e => setFilterType(e.target.value)}
@@ -144,7 +142,42 @@ export default function Sidebar({
</div>
)}
{!loading && Object.entries(groups).map(([cat, items]) => {
{!loading && isExamMode && (
/* ── Exam mode: flat numbered list ─────────────────── */
<div className={styles.flatList}>
{scenarios.map((s, idx) => {
const examDone = examProgress?.[s.id]?.status === 'completed'
const active = s.id === activeId
const hasAttempts = (examProgress?.[s.id]?.attempts || 0) > 0
return (
<button
key={s.id}
className={`${styles.item} ${active ? styles.active : ''} ${examDone ? styles.done : ''}`}
onClick={() => onSelect(s.id)}
>
<div className={styles.itemTop}>
<span className={styles.itemNum}>{idx + 1}</span>
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
<span className={styles.itemTitle}>{s.title}</span>
</div>
<div className={styles.itemMeta}>
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
{totalExamWeight > 0 && (
<span className={styles.examWeightBadge}>
{Math.round((s.weight / totalExamWeight) * 100)}% wt
</span>
)}
{examDone && (
<span className={styles.examCompletedTag}> Completed</span>
)}
</div>
</button>
)
})}
</div>
)}
{!loading && !isExamMode && Object.entries(groups).map(([cat, items]) => {
const catDone = items.filter(s => s.progress?.status === 'completed').length
const isOpen = open[cat] !== false
const hasCatProgress = items.some(s => s.progress?.attempts > 0)
@@ -158,9 +191,7 @@ export default function Sidebar({
</div>
<div className={styles.accordionRight}>
<span className={styles.catCount}>
{isExamMode
? `${items.filter(s => examProgress?.[s.id]?.status === 'completed').length}/${items.length}`
: `${catDone}/${items.length}`}
{`${catDone}/${items.length}`}
</span>
{hasCatProgress && (
<button
@@ -180,24 +211,20 @@ export default function Sidebar({
{isOpen && (
<div className={styles.itemsBox}>
{items.map(s => {
const examDone = isExamMode && examProgress?.[s.id]?.status === 'completed'
const done = !isExamMode && s.progress?.status === 'completed'
const done = s.progress?.status === 'completed'
const active = s.id === activeId
const hasAttempts = isExamMode
? (examProgress?.[s.id]?.attempts || 0) > 0
: s.progress?.attempts > 0
const hasAttempts = s.progress?.attempts > 0
return (
<button
key={s.id}
className={`${styles.item} ${active ? styles.active : ''} ${(done || examDone) ? styles.done : ''}`}
className={`${styles.item} ${active ? styles.active : ''} ${done ? styles.done : ''}`}
onClick={() => onSelect(s.id)}
>
<div className={styles.itemTop}>
<span className={styles.itemNum}>{scenarioIndex[s.id]}</span>
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
<span className={styles.itemTitle}>{s.title}</span>
{(done || examDone) && <span className={styles.checkmark}></span>}
{/* Per-scenario reset — shown when item has attempts */}
{done && <span className={styles.checkmark}></span>}
{hasAttempts && (
<button
className={styles.itemResetBtn}
@@ -212,16 +239,11 @@ export default function Sidebar({
)}
</div>
<div className={styles.itemMeta}>
{!isExamMode && (
<span className={`${styles.diff} ${styles[DIFF_COLOR[s.difficulty]]}`}>
{s.difficulty}
</span>
)}
<span className={`${styles.diff} ${styles[DIFF_COLOR[s.difficulty]]}`}>
{s.difficulty}
</span>
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
{!isExamMode && <span className={styles.weight}>{s.weight}pt</span>}
{isExamMode && examDone && (
<span className={styles.examCompletedTag}> Completed</span>
)}
<span className={styles.weight}>{s.weight}pt</span>
</div>
</button>
)
@@ -70,6 +70,22 @@
padding: 8px 0;
}
.flatList {
overflow-y: auto;
flex: 1;
padding: 6px 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.flatList .item {
border: 1px solid var(--border);
border-radius: var(--radius);
border-bottom: 1px solid var(--border);
}
/* Filters */
.filterBar {
display: flex;
@@ -338,6 +354,18 @@
@keyframes slideIn { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:translateX(0)} }
.examWeightBadge {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 3px;
background: var(--teal-dim);
color: var(--teal);
border: 1px solid color-mix(in srgb, var(--teal) 30%, transparent);
letter-spacing: 0.3px;
}
.examCompletedTag {
font-family: var(--mono);
font-size: 10px;
+4
View File
@@ -19,6 +19,8 @@
--red-dim: rgba(255,107,107,0.10);
--purple: #9775fa;
--purple-dim: rgba(151,117,250,0.10);
--teal: #2dd4bf;
--teal-dim: rgba(45,212,191,0.11);
--text: #e2e8f4;
--text-2: #8899b8;
@@ -52,6 +54,8 @@
--red-dim: rgba(220,38,38,0.10);
--purple: #7c3aed;
--purple-dim: rgba(124,58,237,0.10);
--teal: #0d9488;
--teal-dim: rgba(13,148,136,0.11);
--text: #111827;
--text-2: #374151;