diff --git a/backend/server.js b/backend/server.js index 14d4100..d13fda6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -64,10 +64,21 @@ function getDb() { status TEXT NOT NULL DEFAULT 'active', exam_minutes INTEGER NOT NULL DEFAULT 120, 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 _db.exec(` CREATE TABLE IF NOT EXISTS exam_progress ( @@ -249,20 +260,35 @@ app.post('/api/progress/reset', (req, res) => { // ── 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 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); if (!bundle) return res.status(404).json({ error: 'Bundle not found' }); const db = getDb(); 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 db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now') WHERE status='active'`).run(); 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) - VALUES (?, ?, datetime('now'), 'active', ?)`).run(id, bundleId, mins); - res.json({ id, bundleId, status: 'active', exam_minutes: mins }); + db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes, scenario_ids) + VALUES (?, ?, datetime('now'), 'active', ?, ?)`) + .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 @@ -270,13 +296,18 @@ app.get('/api/sessions/active', (req, res) => { const db = getDb(); const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get(); if (!session) return res.json(null); - const bundle = loadBundles().find(b => b.id === session.bundle_id); - const scenarioIds = bundle?.scenario_ids || []; - // Count completions from exam_progress (exam-specific), not global progress + // 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); + scenarioIds = bundle?.scenario_ids || []; + } const completed = db.prepare( `SELECT COUNT(*) as cnt FROM exam_progress WHERE session_id=? AND status='completed'` ).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 @@ -337,7 +368,11 @@ app.post('/api/sessions/:id/submit', (req, res) => { if (!session) return res.status(404).json({ error: 'Session not found' }); const bundle = loadBundles().find(b => b.id === session.bundle_id); 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' 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); }); -// GET /api/scenarios — list scenarios; optional ?bundle= filter +// GET /api/scenarios — list scenarios; optional ?bundle= and ?session= filter app.get('/api/scenarios', (req, res) => { - const scenarios = loadScenarios(); - const progress = loadProgress(); - const { bundle } = req.query; + const scenarios = loadScenarios() + const progress = loadProgress() + 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) { - const bundles = loadBundles(); - const b = bundles.find(x => x.id === bundle); - if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id)); + const bundles = loadBundles() + const b = bundles.find(x => x.id === bundle) + if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id)) } const list = filtered.map(s => ({ @@ -470,9 +522,9 @@ app.get('/api/scenarios', (req, res) => { type: s.type, weight: s.weight, progress: progress[s.id] || { status: 'not_started', attempts: 0 } - })); - res.json(list); -}); + })) + res.json(list) +}) // GET /api/scenarios/:id — full scenario detail app.get('/api/scenarios/:id', (req, res) => { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6d790c0..40b24d7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (
setShowHistory(true)} /> @@ -290,7 +303,7 @@ export default function App() {
{/* Sidebar */} {/* Sidebar resize handle */} @@ -315,6 +329,7 @@ export default function App() { onScenarioStart={handleScenarioStart} isExamMode={!!examSession} examProgress={examProgress} + totalExamWeight={totalExamWeight} />
@@ -350,9 +365,9 @@ export default function App() { {examModalBundle && ( { + onStart={(mins, count) => { setExamModalBundle(null) - startExam(examModalBundle.id, mins) + startExam(examModalBundle.id, mins, count) }} onCancel={() => setExamModalBundle(null)} /> diff --git a/frontend/src/components/ExamStartModal.jsx b/frontend/src/components/ExamStartModal.jsx index d61ae14..6e012eb 100644 --- a/frontend/src/components/ExamStartModal.jsx +++ b/frontend/src/components/ExamStartModal.jsx @@ -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 }) {
📋 - {bundle.scenario_ids?.length || '?'} scenarios · Recommended: {bundle.exam_minutes} min + {totalScenarios} scenarios · Recommended: {bundle.exam_minutes} min
+ {/* Duration field */}
@@ -73,13 +84,46 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) { minutes
- {!isValid ? ( + {!isMinutesValid ? ( ⚠ Duration must be between 5 and 300 minutes ) : numMinutes < 60 ? '⚡ Speed run mode' : numMinutes <= 120 ? '🎯 Realistic exam timing' : '🧘 Relaxed practice pace'}
+ + {/* Scenario count field */} +
+ +
+ setScenarioCount(Number(e.target.value))} + style={{ '--bcolor': bundle.color }} + /> + setScenarioCount(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleStart()} + /> +
+
+ {!isScenariosValid ? ( + ⚠ Must be between 1 and {totalScenarios} + ) : numScenarios === totalScenarios + ? `📚 Full exam — all ${totalScenarios} scenarios` + : `🎯 ${numScenarios} randomly selected scenario${numScenarios > 1 ? 's' : ''}`} +
+
diff --git a/frontend/src/components/ExamStartModal.module.css b/frontend/src/components/ExamStartModal.module.css index 127ecb3..bf79abb 100644 --- a/frontend/src/components/ExamStartModal.module.css +++ b/frontend/src/components/ExamStartModal.module.css @@ -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; diff --git a/frontend/src/components/ScenarioPanel.jsx b/frontend/src/components/ScenarioPanel.jsx index a597770..61ef502 100644 --- a/frontend/src/components/ScenarioPanel.jsx +++ b/frontend/src/components/ScenarioPanel.jsx @@ -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 )} {scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'} {!isExamMode && {scenario.weight} pts} + {isExamMode && totalExamWeight > 0 && ( + + {Math.round((scenario.weight / totalExamWeight) * 100)}% weight + + )}
{scenario.title}
diff --git a/frontend/src/components/ScenarioPanel.module.css b/frontend/src/components/ScenarioPanel.module.css index 134cae1..b55dfeb 100644 --- a/frontend/src/components/ScenarioPanel.module.css +++ b/frontend/src/components/ScenarioPanel.module.css @@ -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; diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index d546014..9a1c1ad 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -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({
- {/* Filter bar */} - {!collapsed && ( + {/* Filter bar — hidden in exam mode */} + {!collapsed && !isExamMode && (
- {!isExamMode && ( - - )} +