diff --git a/backend/server.js b/backend/server.js
index 080c540..0675a0f 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -67,6 +67,19 @@ function getDb() {
snapshot TEXT
)
`);
+
+ // Separate exam-session progress table β tracks completions per session
+ _db.exec(`
+ CREATE TABLE IF NOT EXISTS exam_progress (
+ session_id TEXT NOT NULL,
+ scenario_id TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'in_progress',
+ attempts INTEGER NOT NULL DEFAULT 0,
+ completed_at TEXT,
+ PRIMARY KEY (session_id, scenario_id),
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
+ )
+ `);
return _db;
}
@@ -258,27 +271,85 @@ app.get('/api/sessions/active', (req, res) => {
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 progress = loadProgress();
const scenarioIds = bundle?.scenario_ids || [];
- const completed = scenarioIds.filter(id => progress[id]?.status === 'completed').length;
+ // Count completions from exam_progress (exam-specific), not global progress
+ 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 });
});
+// GET /api/sessions/:id/exam-progress β return per-scenario progress for an exam session
+app.get('/api/sessions/:id/exam-progress', (req, res) => {
+ const db = getDb();
+ const rows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id);
+ // Return as a map: scenario_id -> { status, attempts, completed_at }
+ const result = {};
+ for (const r of rows) {
+ result[r.scenario_id] = { status: r.status, attempts: r.attempts, completed_at: r.completed_at };
+ }
+ res.json(result);
+});
+
+// GET /api/sessions/history β list all completed/abandoned exam sessions (newest first)
+app.get('/api/sessions/history', (req, res) => {
+ const db = getDb();
+ const sessions = db.prepare(
+ `SELECT * FROM sessions WHERE status != 'active' ORDER BY started_at DESC`
+ ).all();
+ const bundles = loadBundles();
+
+ const result = sessions.map(s => {
+ const bundle = bundles.find(b => b.id === s.bundle_id);
+ let snapshot = [];
+ try { snapshot = s.snapshot ? JSON.parse(s.snapshot) : []; } catch (_) {}
+ const completed = snapshot.filter(x => x.status === 'completed');
+ const totalWeight = snapshot.reduce((a, x) => a + (x.weight || 0), 0);
+ const earnedWeight = completed.reduce((a, x) => a + (x.weight || 0), 0);
+ const pct = totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0;
+ return {
+ id: s.id,
+ bundle_id: s.bundle_id,
+ bundle_name: bundle?.name || s.bundle_id,
+ bundle_icon: bundle?.icon || 'π',
+ status: s.status,
+ started_at: s.started_at,
+ submitted_at: s.submitted_at,
+ exam_minutes: s.exam_minutes,
+ duration_secs: s.duration_secs,
+ scenarioCount: snapshot.length,
+ completedCount: completed.length,
+ totalWeight,
+ earnedWeight,
+ pct,
+ passed: pct >= 66,
+ snapshot,
+ };
+ });
+
+ res.json(result);
+});
+
// POST /api/sessions/:id/submit β submit the exam session
app.post('/api/sessions/:id/submit', (req, res) => {
const db = getDb();
const session = db.prepare(`SELECT * FROM sessions WHERE id=?`).get(req.params.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
const bundle = loadBundles().find(b => b.id === session.bundle_id);
- const progress = loadProgress();
const scenarios = loadScenarios();
const bundleScenarios = scenarios.filter(s => bundle?.scenario_ids?.includes(s.id));
+
+ // 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 examProgressMap = {};
+ for (const r of examProgressRows) examProgressMap[r.scenario_id] = r;
+
const snapshot = bundleScenarios.map(s => ({
id: s.id, title: s.title, weight: s.weight,
category: s.category, type: s.type, difficulty: s.difficulty,
- status: progress[s.id]?.status || 'not_started',
- completed_at: progress[s.id]?.completed_at || null,
- attempts: progress[s.id]?.attempts || 0,
+ status: examProgressMap[s.id]?.status || 'not_started',
+ completed_at: examProgressMap[s.id]?.completed_at || null,
+ attempts: examProgressMap[s.id]?.attempts || 0,
}));
const startedAt = new Date(session.started_at + 'Z');
const durationSecs = Math.round((Date.now() - startedAt.getTime()) / 1000);
@@ -474,7 +545,7 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
if (!passed) allPassed = false;
}
- // Update progress
+ // Update global practice progress
const progress = loadProgress();
const prev = progress[scenario.id] || { attempts: 0 };
progress[scenario.id] = {
@@ -486,6 +557,28 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
};
saveProgress(progress);
+ // Also update exam_progress if there's an active session
+ const db = getDb();
+ const activeSession = db.prepare(`SELECT id FROM sessions WHERE status='active' LIMIT 1`).get();
+ if (activeSession) {
+ const ep = db.prepare(`SELECT * FROM exam_progress WHERE session_id=? AND scenario_id=?`)
+ .get(activeSession.id, scenario.id);
+ const epAttempts = (ep?.attempts || 0) + 1;
+ db.prepare(`
+ INSERT INTO exam_progress (session_id, scenario_id, status, attempts, completed_at)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(session_id, scenario_id) DO UPDATE SET
+ status = excluded.status,
+ attempts = excluded.attempts,
+ completed_at = COALESCE(excluded.completed_at, exam_progress.completed_at)
+ `).run(
+ activeSession.id, scenario.id,
+ allPassed ? 'completed' : 'in_progress',
+ epAttempts,
+ allPassed ? new Date().toISOString() : null
+ );
+ }
+
res.json({ passed: allPassed, checks, attempts: progress[scenario.id].attempts });
});
@@ -499,6 +592,7 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
const correct = selected === scenario.correct_option;
+ // Update global practice progress
const progress = loadProgress();
const prev = progress[scenario.id] || { attempts: 0 };
progress[scenario.id] = {
@@ -511,6 +605,28 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
};
saveProgress(progress);
+ // Also update exam_progress if there's an active session
+ const db = getDb();
+ const activeSession = db.prepare(`SELECT id FROM sessions WHERE status='active' LIMIT 1`).get();
+ if (activeSession) {
+ const ep = db.prepare(`SELECT * FROM exam_progress WHERE session_id=? AND scenario_id=?`)
+ .get(activeSession.id, scenario.id);
+ const epAttempts = (ep?.attempts || 0) + 1;
+ db.prepare(`
+ INSERT INTO exam_progress (session_id, scenario_id, status, attempts, completed_at)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(session_id, scenario_id) DO UPDATE SET
+ status = excluded.status,
+ attempts = excluded.attempts,
+ completed_at = COALESCE(excluded.completed_at, exam_progress.completed_at)
+ `).run(
+ activeSession.id, scenario.id,
+ correct ? 'completed' : 'in_progress',
+ epAttempts,
+ correct ? new Date().toISOString() : null
+ );
+ }
+
res.json({
correct,
correct_option: scenario.correct_option,
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index e2975d3..6d790c0 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -7,6 +7,7 @@ import BundleNav from './components/BundleNav'
import ExamTimer from './components/ExamTimer'
import ExamReport from './components/ExamReport'
import ExamStartModal from './components/ExamStartModal'
+import ExamHistory from './components/ExamHistory'
import styles from './App.module.css'
const MIN_SIDEBAR_W = 180
@@ -35,6 +36,8 @@ export default function App() {
const [examSession, setExamSession] = useState(null) // active session object
const [examReport, setExamReport] = useState(null) // submitted report
const [examModalBundle, setExamModalBundle] = useState(null) // bundle for start/retry modal
+ const [examProgress, setExamProgress] = useState({}) // exam-specific per-scenario progress
+ const [showHistory, setShowHistory] = useState(false) // exam history modal
// Sidebar resize / collapse
const [sidebarW, setSidebarW] = useState(DEFAULT_SIDEBAR_W)
@@ -82,10 +85,13 @@ export default function App() {
useEffect(() => {
fetch('/api/sessions/active')
.then(r => r.json())
- .then(s => {
+ .then(async s => {
if (s) {
setExamSession(s)
setActiveBundleId(s.bundle_id)
+ // Load exam-specific progress
+ const ep = await fetch(`/api/sessions/${s.id}/exam-progress`).then(r => r.json()).catch(() => ({}))
+ setExamProgress(ep || {})
}
})
.catch(() => { })
@@ -142,10 +148,14 @@ export default function App() {
const d2 = await fetch(`/api/scenarios/${activeId}`).then(r => r.json())
setScenario(d2)
}
- // Refresh exam session completion count
+ // Refresh exam session completion count + exam-specific progress
if (examSession) {
const updated = await fetch('/api/sessions/active').then(r => r.json()).catch(() => null)
- if (updated) setExamSession(updated)
+ if (updated) {
+ setExamSession(updated)
+ const ep = await fetch(`/api/sessions/${updated.id}/exam-progress`).then(r => r.json()).catch(() => ({}))
+ setExamProgress(ep || {})
+ }
}
}, [activeBundleId, activeId, examSession])
@@ -171,6 +181,7 @@ export default function App() {
const bundle = bundles.find(b => b.id === examSession.bundle_id)
setExamReport({ ...result, bundle })
setExamSession(null)
+ setExamProgress({})
refreshProgress()
}, [examSession, bundles, refreshProgress])
@@ -178,6 +189,7 @@ export default function App() {
if (!examSession) return
await fetch(`/api/sessions/${examSession.id}/abandon`, { method: 'POST' }).catch(() => { })
setExamSession(null)
+ setExamProgress({})
refreshProgress()
}, [examSession, refreshProgress])
@@ -247,7 +259,7 @@ export default function App() {
return (
-
+ setShowHistory(true)} />
{/* Bundle navigation bar */}
{/* Sidebar resize handle */}
@@ -300,6 +314,7 @@ export default function App() {
onProgressUpdate={refreshProgress}
onScenarioStart={handleScenarioStart}
isExamMode={!!examSession}
+ examProgress={examProgress}
/>
@@ -342,6 +357,11 @@ export default function App() {
onCancel={() => setExamModalBundle(null)}
/>
)}
+ {/* Exam history modal */}
+ {showHistory && (
+ setShowHistory(false)} />
+ )}
+