feat(exam): add exam history and improve scenario timer

Signed-off-by: Abhinav Sinha <[email protected]>
This commit is contained in:
Abhinav Sinha
2026-06-16 16:03:08 +05:30
parent 5720b59a5f
commit 6766d772d1
11 changed files with 1403 additions and 108 deletions
+123 -7
View File
@@ -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,