refactor(exam): flat sidebar list, weight badges, UI cleanup
Signed-off-by: Abhinav Sinha <[email protected]>
This commit is contained in:
+72
-20
@@ -64,10 +64,21 @@ function getDb() {
|
|||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
exam_minutes INTEGER NOT NULL DEFAULT 120,
|
exam_minutes INTEGER NOT NULL DEFAULT 120,
|
||||||
duration_secs INTEGER,
|
duration_secs INTEGER,
|
||||||
snapshot TEXT
|
snapshot TEXT,
|
||||||
|
scenario_ids TEXT
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migrate: add scenario_ids column if missing
|
||||||
|
try {
|
||||||
|
const cols = _db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name);
|
||||||
|
if (!cols.includes('scenario_ids')) {
|
||||||
|
_db.exec(`ALTER TABLE sessions ADD COLUMN scenario_ids TEXT`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to migrate sessions table:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
// Separate exam-session progress table — tracks completions per session
|
// Separate exam-session progress table — tracks completions per session
|
||||||
_db.exec(`
|
_db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS exam_progress (
|
CREATE TABLE IF NOT EXISTS exam_progress (
|
||||||
@@ -249,20 +260,35 @@ app.post('/api/progress/reset', (req, res) => {
|
|||||||
|
|
||||||
// ── Exam sessions ─────────────────────────────────────────────────────────────
|
// ── Exam sessions ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Fisher-Yates shuffle (returns a new array)
|
||||||
|
function shuffle(arr) {
|
||||||
|
const a = [...arr];
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/sessions — start a new exam session
|
// POST /api/sessions — start a new exam session
|
||||||
app.post('/api/sessions', (req, res) => {
|
app.post('/api/sessions', (req, res) => {
|
||||||
const { bundleId, examMinutes } = req.body;
|
const { bundleId, examMinutes, scenarioCount } = req.body;
|
||||||
const bundle = loadBundles().find(b => b.id === bundleId);
|
const bundle = loadBundles().find(b => b.id === bundleId);
|
||||||
if (!bundle) return res.status(404).json({ error: 'Bundle not found' });
|
if (!bundle) return res.status(404).json({ error: 'Bundle not found' });
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const mins = Math.max(5, Math.min(300, Number(examMinutes) || bundle.exam_minutes || 120));
|
const mins = Math.max(5, Math.min(300, Number(examMinutes) || bundle.exam_minutes || 120));
|
||||||
|
// Shuffle and optionally slice scenario IDs
|
||||||
|
const allIds = bundle.scenario_ids || [];
|
||||||
|
const count = Math.max(1, Math.min(allIds.length, Number(scenarioCount) || allIds.length));
|
||||||
|
const sessionScenarioIds = shuffle(allIds).slice(0, count);
|
||||||
// Abandon any existing active session
|
// Abandon any existing active session
|
||||||
db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now')
|
db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now')
|
||||||
WHERE status='active'`).run();
|
WHERE status='active'`).run();
|
||||||
const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes)
|
db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes, scenario_ids)
|
||||||
VALUES (?, ?, datetime('now'), 'active', ?)`).run(id, bundleId, mins);
|
VALUES (?, ?, datetime('now'), 'active', ?, ?)`)
|
||||||
res.json({ id, bundleId, status: 'active', exam_minutes: mins });
|
.run(id, bundleId, mins, JSON.stringify(sessionScenarioIds));
|
||||||
|
res.json({ id, bundleId, status: 'active', exam_minutes: mins, scenario_ids: sessionScenarioIds });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/sessions/active — get the current active session
|
// GET /api/sessions/active — get the current active session
|
||||||
@@ -270,13 +296,18 @@ app.get('/api/sessions/active', (req, res) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get();
|
const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get();
|
||||||
if (!session) return res.json(null);
|
if (!session) return res.json(null);
|
||||||
|
// Use session-specific scenario_ids (shuffled/sliced at start time)
|
||||||
|
let scenarioIds = [];
|
||||||
|
try { scenarioIds = session.scenario_ids ? JSON.parse(session.scenario_ids) : []; } catch (_) {}
|
||||||
|
if (!scenarioIds.length) {
|
||||||
|
// Fallback for sessions created before this feature
|
||||||
const bundle = loadBundles().find(b => b.id === session.bundle_id);
|
const bundle = loadBundles().find(b => b.id === session.bundle_id);
|
||||||
const scenarioIds = bundle?.scenario_ids || [];
|
scenarioIds = bundle?.scenario_ids || [];
|
||||||
// Count completions from exam_progress (exam-specific), not global progress
|
}
|
||||||
const completed = db.prepare(
|
const completed = db.prepare(
|
||||||
`SELECT COUNT(*) as cnt FROM exam_progress WHERE session_id=? AND status='completed'`
|
`SELECT COUNT(*) as cnt FROM exam_progress WHERE session_id=? AND status='completed'`
|
||||||
).get(session.id)?.cnt || 0;
|
).get(session.id)?.cnt || 0;
|
||||||
res.json({ ...session, scenarioCount: scenarioIds.length, completedCount: completed });
|
res.json({ ...session, scenario_ids: scenarioIds, scenarioCount: scenarioIds.length, completedCount: completed });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/sessions/:id/exam-progress — return per-scenario progress for an exam session
|
// GET /api/sessions/:id/exam-progress — return per-scenario progress for an exam session
|
||||||
@@ -337,7 +368,11 @@ app.post('/api/sessions/:id/submit', (req, res) => {
|
|||||||
if (!session) return res.status(404).json({ error: 'Session not found' });
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
||||||
const bundle = loadBundles().find(b => b.id === session.bundle_id);
|
const bundle = loadBundles().find(b => b.id === session.bundle_id);
|
||||||
const scenarios = loadScenarios();
|
const scenarios = loadScenarios();
|
||||||
const bundleScenarios = scenarios.filter(s => bundle?.scenario_ids?.includes(s.id));
|
// Use session-specific scenario IDs (preserves shuffle order)
|
||||||
|
let sessionIds = [];
|
||||||
|
try { sessionIds = session.scenario_ids ? JSON.parse(session.scenario_ids) : []; } catch (_) {}
|
||||||
|
if (!sessionIds.length) sessionIds = bundle?.scenario_ids || [];
|
||||||
|
const bundleScenarios = sessionIds.map(id => scenarios.find(s => s.id === id)).filter(Boolean);
|
||||||
|
|
||||||
// Build snapshot from exam_progress (exam-specific), falling back to 'not_started'
|
// Build snapshot from exam_progress (exam-specific), falling back to 'not_started'
|
||||||
const examProgressRows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id);
|
const examProgressRows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id);
|
||||||
@@ -449,17 +484,34 @@ app.get('/api/bundles', (req, res) => {
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/scenarios — list scenarios; optional ?bundle=<id> filter
|
// GET /api/scenarios — list scenarios; optional ?bundle=<id> and ?session=<id> filter
|
||||||
app.get('/api/scenarios', (req, res) => {
|
app.get('/api/scenarios', (req, res) => {
|
||||||
const scenarios = loadScenarios();
|
const scenarios = loadScenarios()
|
||||||
const progress = loadProgress();
|
const progress = loadProgress()
|
||||||
const { bundle } = req.query;
|
const { bundle, session: sessionId } = req.query
|
||||||
|
|
||||||
let filtered = scenarios;
|
// Session-scoped: return only session scenario_ids in their shuffled order
|
||||||
|
if (sessionId) {
|
||||||
|
const db = getDb()
|
||||||
|
const session = db.prepare(`SELECT scenario_ids FROM sessions WHERE id=?`).get(sessionId)
|
||||||
|
let sessionIds = []
|
||||||
|
try { sessionIds = session?.scenario_ids ? JSON.parse(session.scenario_ids) : [] } catch (_) {}
|
||||||
|
const list = sessionIds
|
||||||
|
.map(id => scenarios.find(s => s.id === id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(s => ({
|
||||||
|
id: s.id, title: s.title, category: s.category,
|
||||||
|
difficulty: s.difficulty, type: s.type, weight: s.weight,
|
||||||
|
progress: progress[s.id] || { status: 'not_started', attempts: 0 }
|
||||||
|
}))
|
||||||
|
return res.json(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
let filtered = scenarios
|
||||||
if (bundle) {
|
if (bundle) {
|
||||||
const bundles = loadBundles();
|
const bundles = loadBundles()
|
||||||
const b = bundles.find(x => x.id === bundle);
|
const b = bundles.find(x => x.id === bundle)
|
||||||
if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id));
|
if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = filtered.map(s => ({
|
const list = filtered.map(s => ({
|
||||||
@@ -470,9 +522,9 @@ app.get('/api/scenarios', (req, res) => {
|
|||||||
type: s.type,
|
type: s.type,
|
||||||
weight: s.weight,
|
weight: s.weight,
|
||||||
progress: progress[s.id] || { status: 'not_started', attempts: 0 }
|
progress: progress[s.id] || { status: 'not_started', attempts: 0 }
|
||||||
}));
|
}))
|
||||||
res.json(list);
|
res.json(list)
|
||||||
});
|
})
|
||||||
|
|
||||||
// GET /api/scenarios/:id — full scenario detail
|
// GET /api/scenarios/:id — full scenario detail
|
||||||
app.get('/api/scenarios/:id', (req, res) => {
|
app.get('/api/scenarios/:id', (req, res) => {
|
||||||
|
|||||||
+24
-9
@@ -103,7 +103,10 @@ export default function App() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setActiveId(null)
|
setActiveId(null)
|
||||||
setScenario(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)
|
fetch(url)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -112,7 +115,7 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [activeBundleId])
|
}, [activeBundleId, examSession?.id])
|
||||||
|
|
||||||
// ── Load full scenario when selected — teardown previous, load new ─────────
|
// ── Load full scenario when selected — teardown previous, load new ─────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -137,9 +140,12 @@ export default function App() {
|
|||||||
}, [activeId])
|
}, [activeId])
|
||||||
|
|
||||||
const refreshProgress = useCallback(async () => {
|
const refreshProgress = useCallback(async () => {
|
||||||
|
const scenarioUrl = examSession?.id
|
||||||
|
? `/api/scenarios?session=${examSession.id}`
|
||||||
|
: `/api/scenarios?bundle=${activeBundleId}`
|
||||||
const [bundleData, scenarioData] = await Promise.all([
|
const [bundleData, scenarioData] = await Promise.all([
|
||||||
fetch('/api/bundles').then(r => r.json()),
|
fetch('/api/bundles').then(r => r.json()),
|
||||||
fetch(`/api/scenarios?bundle=${activeBundleId}`).then(r => r.json()),
|
fetch(scenarioUrl).then(r => r.json()),
|
||||||
])
|
])
|
||||||
setBundles(bundleData)
|
setBundles(bundleData)
|
||||||
setScenarios(scenarioData)
|
setScenarios(scenarioData)
|
||||||
@@ -160,13 +166,13 @@ export default function App() {
|
|||||||
}, [activeBundleId, activeId, examSession])
|
}, [activeBundleId, activeId, examSession])
|
||||||
|
|
||||||
// ── Exam actions ──────────────────────────────────────────────────────────
|
// ── Exam actions ──────────────────────────────────────────────────────────
|
||||||
const startExam = useCallback(async (bundleId, customMinutes) => {
|
const startExam = useCallback(async (bundleId, customMinutes, customScenarioCount) => {
|
||||||
const res = await fetch('/api/sessions', {
|
const res = await fetch('/api/sessions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ bundleId, examMinutes: customMinutes }),
|
body: JSON.stringify({ bundleId, examMinutes: customMinutes, scenarioCount: customScenarioCount }),
|
||||||
}).then(r => r.json())
|
}).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())
|
const active = await fetch('/api/sessions/active').then(r => r.json())
|
||||||
setExamSession(active)
|
setExamSession(active)
|
||||||
setActiveBundleId(bundleId)
|
setActiveBundleId(bundleId)
|
||||||
@@ -257,6 +263,13 @@ export default function App() {
|
|||||||
const activeBundle = bundles.find(b => b.id === activeBundleId) || null
|
const activeBundle = bundles.find(b => b.id === activeBundleId) || null
|
||||||
const isMcq = scenario?.type === 'mcq'
|
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 (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
<Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} />
|
<Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} />
|
||||||
@@ -290,7 +303,7 @@ export default function App() {
|
|||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<Sidebar
|
<Sidebar
|
||||||
scenarios={scenarios}
|
scenarios={!!examSession ? examScenarios : scenarios}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
onSelect={setActiveId}
|
onSelect={setActiveId}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -301,6 +314,7 @@ export default function App() {
|
|||||||
onProgressUpdate={refreshProgress}
|
onProgressUpdate={refreshProgress}
|
||||||
isExamMode={!!examSession}
|
isExamMode={!!examSession}
|
||||||
examProgress={examProgress}
|
examProgress={examProgress}
|
||||||
|
totalExamWeight={totalExamWeight}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sidebar resize handle */}
|
{/* Sidebar resize handle */}
|
||||||
@@ -315,6 +329,7 @@ export default function App() {
|
|||||||
onScenarioStart={handleScenarioStart}
|
onScenarioStart={handleScenarioStart}
|
||||||
isExamMode={!!examSession}
|
isExamMode={!!examSession}
|
||||||
examProgress={examProgress}
|
examProgress={examProgress}
|
||||||
|
totalExamWeight={totalExamWeight}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -350,9 +365,9 @@ export default function App() {
|
|||||||
{examModalBundle && (
|
{examModalBundle && (
|
||||||
<ExamStartModal
|
<ExamStartModal
|
||||||
bundle={examModalBundle}
|
bundle={examModalBundle}
|
||||||
onStart={mins => {
|
onStart={(mins, count) => {
|
||||||
setExamModalBundle(null)
|
setExamModalBundle(null)
|
||||||
startExam(examModalBundle.id, mins)
|
startExam(examModalBundle.id, mins, count)
|
||||||
}}
|
}}
|
||||||
onCancel={() => setExamModalBundle(null)}
|
onCancel={() => setExamModalBundle(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ import { useState, useEffect, useRef } from 'react'
|
|||||||
import styles from './ExamStartModal.module.css'
|
import styles from './ExamStartModal.module.css'
|
||||||
|
|
||||||
export default function ExamStartModal({ bundle, onStart, onCancel }) {
|
export default function ExamStartModal({ bundle, onStart, onCancel }) {
|
||||||
|
const totalScenarios = bundle?.scenario_ids?.length || 0
|
||||||
const [minutes, setMinutes] = useState(bundle?.exam_minutes || 120)
|
const [minutes, setMinutes] = useState(bundle?.exam_minutes || 120)
|
||||||
|
const [scenarioCount, setScenarioCount] = useState(totalScenarios)
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
|
|
||||||
|
// Keep scenarioCount in sync if bundle changes
|
||||||
useEffect(() => {
|
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)
|
const t = setTimeout(() => inputRef.current?.select(), 60)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -14,11 +21,14 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
|
|||||||
if (!bundle) return null
|
if (!bundle) return null
|
||||||
|
|
||||||
const numMinutes = Number(minutes)
|
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 = () => {
|
const handleStart = () => {
|
||||||
if (!isValid) return
|
if (!isValid) return
|
||||||
onStart(numMinutes)
|
onStart(numMinutes, numScenarios)
|
||||||
}
|
}
|
||||||
|
|
||||||
const presets = [
|
const presets = [
|
||||||
@@ -42,9 +52,10 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
|
|||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<div className={styles.info}>
|
<div className={styles.info}>
|
||||||
<span>📋</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Duration field */}
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label className={styles.label}>Exam Duration</label>
|
<label className={styles.label}>Exam Duration</label>
|
||||||
<div className={styles.presets}>
|
<div className={styles.presets}>
|
||||||
@@ -73,13 +84,46 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
|
|||||||
<span className={styles.unit}>minutes</span>
|
<span className={styles.unit}>minutes</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.hint}>
|
<div className={styles.hint}>
|
||||||
{!isValid ? (
|
{!isMinutesValid ? (
|
||||||
<span style={{ color: 'var(--red)' }}>⚠ Duration must be between 5 and 300 minutes</span>
|
<span style={{ color: 'var(--red)' }}>⚠ Duration must be between 5 and 300 minutes</span>
|
||||||
) : numMinutes < 60 ? '⚡ Speed run mode' :
|
) : numMinutes < 60 ? '⚡ Speed run mode' :
|
||||||
numMinutes <= 120 ? '🎯 Realistic exam timing' :
|
numMinutes <= 120 ? '🎯 Realistic exam timing' :
|
||||||
'🧘 Relaxed practice pace'}
|
'🧘 Relaxed practice pace'}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
|
|||||||
@@ -116,6 +116,24 @@
|
|||||||
gap: 8px;
|
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 {
|
.input {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
|||||||
@@ -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 [tab, setTab] = useState('problem')
|
||||||
const [setupState, setSetupState] = useState('idle') // idle | running | done | error
|
const [setupState, setSetupState] = useState('idle') // idle | running | done | error
|
||||||
const [validating, setValidating] = useState(false)
|
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>
|
<span className={styles.typeTag}>{scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'}</span>
|
||||||
{!isExamMode && <span className={styles.weight}>{scenario.weight} pts</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>
|
||||||
<div className={styles.titleRow}>
|
<div className={styles.titleRow}>
|
||||||
<div className={styles.scenarioTitle}>{scenario.title}</div>
|
<div className={styles.scenarioTitle}>{scenario.title}</div>
|
||||||
|
|||||||
@@ -76,6 +76,19 @@
|
|||||||
margin-left: auto;
|
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 {
|
.scenarioTitle {
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function Sidebar({
|
|||||||
scenarios, activeId, onSelect, loading,
|
scenarios, activeId, onSelect, loading,
|
||||||
collapsed, onToggleCollapse, width,
|
collapsed, onToggleCollapse, width,
|
||||||
activeBundleId, onProgressUpdate,
|
activeBundleId, onProgressUpdate,
|
||||||
isExamMode, examProgress,
|
isExamMode, examProgress, totalExamWeight,
|
||||||
}) {
|
}) {
|
||||||
const [filterDiff, setFilterDiff] = useState('All')
|
const [filterDiff, setFilterDiff] = useState('All')
|
||||||
const [filterType, setFilterType] = useState('All')
|
const [filterType, setFilterType] = useState('All')
|
||||||
@@ -106,10 +106,9 @@ export default function Sidebar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter bar */}
|
{/* Filter bar — hidden in exam mode */}
|
||||||
{!collapsed && (
|
{!collapsed && !isExamMode && (
|
||||||
<div className={styles.filterBar}>
|
<div className={styles.filterBar}>
|
||||||
{!isExamMode && (
|
|
||||||
<select
|
<select
|
||||||
value={filterDiff}
|
value={filterDiff}
|
||||||
onChange={e => setFilterDiff(e.target.value)}
|
onChange={e => setFilterDiff(e.target.value)}
|
||||||
@@ -120,7 +119,6 @@ export default function Sidebar({
|
|||||||
<option value="Medium">Medium</option>
|
<option value="Medium">Medium</option>
|
||||||
<option value="Hard">Hard</option>
|
<option value="Hard">Hard</option>
|
||||||
</select>
|
</select>
|
||||||
)}
|
|
||||||
<select
|
<select
|
||||||
value={filterType}
|
value={filterType}
|
||||||
onChange={e => setFilterType(e.target.value)}
|
onChange={e => setFilterType(e.target.value)}
|
||||||
@@ -144,7 +142,42 @@ export default function Sidebar({
|
|||||||
</div>
|
</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 catDone = items.filter(s => s.progress?.status === 'completed').length
|
||||||
const isOpen = open[cat] !== false
|
const isOpen = open[cat] !== false
|
||||||
const hasCatProgress = items.some(s => s.progress?.attempts > 0)
|
const hasCatProgress = items.some(s => s.progress?.attempts > 0)
|
||||||
@@ -158,9 +191,7 @@ export default function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.accordionRight}>
|
<div className={styles.accordionRight}>
|
||||||
<span className={styles.catCount}>
|
<span className={styles.catCount}>
|
||||||
{isExamMode
|
{`${catDone}/${items.length}`}
|
||||||
? `${items.filter(s => examProgress?.[s.id]?.status === 'completed').length}/${items.length}`
|
|
||||||
: `${catDone}/${items.length}`}
|
|
||||||
</span>
|
</span>
|
||||||
{hasCatProgress && (
|
{hasCatProgress && (
|
||||||
<button
|
<button
|
||||||
@@ -180,24 +211,20 @@ export default function Sidebar({
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className={styles.itemsBox}>
|
<div className={styles.itemsBox}>
|
||||||
{items.map(s => {
|
{items.map(s => {
|
||||||
const examDone = isExamMode && examProgress?.[s.id]?.status === 'completed'
|
const done = s.progress?.status === 'completed'
|
||||||
const done = !isExamMode && s.progress?.status === 'completed'
|
|
||||||
const active = s.id === activeId
|
const active = s.id === activeId
|
||||||
const hasAttempts = isExamMode
|
const hasAttempts = s.progress?.attempts > 0
|
||||||
? (examProgress?.[s.id]?.attempts || 0) > 0
|
|
||||||
: s.progress?.attempts > 0
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
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)}
|
onClick={() => onSelect(s.id)}
|
||||||
>
|
>
|
||||||
<div className={styles.itemTop}>
|
<div className={styles.itemTop}>
|
||||||
<span className={styles.itemNum}>{scenarioIndex[s.id]}</span>
|
<span className={styles.itemNum}>{scenarioIndex[s.id]}</span>
|
||||||
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
|
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
|
||||||
<span className={styles.itemTitle}>{s.title}</span>
|
<span className={styles.itemTitle}>{s.title}</span>
|
||||||
{(done || examDone) && <span className={styles.checkmark}>✓</span>}
|
{done && <span className={styles.checkmark}>✓</span>}
|
||||||
{/* Per-scenario reset — shown when item has attempts */}
|
|
||||||
{hasAttempts && (
|
{hasAttempts && (
|
||||||
<button
|
<button
|
||||||
className={styles.itemResetBtn}
|
className={styles.itemResetBtn}
|
||||||
@@ -212,16 +239,11 @@ export default function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.itemMeta}>
|
<div className={styles.itemMeta}>
|
||||||
{!isExamMode && (
|
|
||||||
<span className={`${styles.diff} ${styles[DIFF_COLOR[s.difficulty]]}`}>
|
<span className={`${styles.diff} ${styles[DIFF_COLOR[s.difficulty]]}`}>
|
||||||
{s.difficulty}
|
{s.difficulty}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
|
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
|
||||||
{!isExamMode && <span className={styles.weight}>{s.weight}pt</span>}
|
<span className={styles.weight}>{s.weight}pt</span>
|
||||||
{isExamMode && examDone && (
|
|
||||||
<span className={styles.examCompletedTag}>✓ Completed</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -70,6 +70,22 @@
|
|||||||
padding: 8px 0;
|
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 */
|
/* Filters */
|
||||||
.filterBar {
|
.filterBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -338,6 +354,18 @@
|
|||||||
|
|
||||||
@keyframes slideIn { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:translateX(0)} }
|
@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 {
|
.examCompletedTag {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
--red-dim: rgba(255,107,107,0.10);
|
--red-dim: rgba(255,107,107,0.10);
|
||||||
--purple: #9775fa;
|
--purple: #9775fa;
|
||||||
--purple-dim: rgba(151,117,250,0.10);
|
--purple-dim: rgba(151,117,250,0.10);
|
||||||
|
--teal: #2dd4bf;
|
||||||
|
--teal-dim: rgba(45,212,191,0.11);
|
||||||
|
|
||||||
--text: #e2e8f4;
|
--text: #e2e8f4;
|
||||||
--text-2: #8899b8;
|
--text-2: #8899b8;
|
||||||
@@ -52,6 +54,8 @@
|
|||||||
--red-dim: rgba(220,38,38,0.10);
|
--red-dim: rgba(220,38,38,0.10);
|
||||||
--purple: #7c3aed;
|
--purple: #7c3aed;
|
||||||
--purple-dim: rgba(124,58,237,0.10);
|
--purple-dim: rgba(124,58,237,0.10);
|
||||||
|
--teal: #0d9488;
|
||||||
|
--teal-dim: rgba(13,148,136,0.11);
|
||||||
|
|
||||||
--text: #111827;
|
--text: #111827;
|
||||||
--text-2: #374151;
|
--text-2: #374151;
|
||||||
|
|||||||
Reference in New Issue
Block a user