feat(exam): add timer for each scenario in exam mode

Signed-off-by: Abhinav Sinha <[email protected]>
This commit is contained in:
Abhinav Sinha
2026-06-16 10:53:14 +05:30
parent ab2403e914
commit 5720b59a5f
4 changed files with 252 additions and 20 deletions
+113 -14
View File
@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { execSync, exec } = require('child_process'); const { exec } = require('child_process');
const { promisify } = require('util'); const { promisify } = require('util');
const execAsync = promisify(exec); const execAsync = promisify(exec);
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
@@ -33,6 +33,28 @@ function getDb() {
completed_at TEXT completed_at TEXT
) )
`); `);
// Run dynamic schema migrations to add missing columns to the progress table
try {
const tableInfo = _db.prepare("PRAGMA table_info(progress)").all();
const existingColumns = tableInfo.map(col => col.name);
const requiredColumns = [
{ name: 'started_at', type: 'TEXT' },
{ name: 'notes', type: 'TEXT' },
{ name: 'time_spent_seconds', type: 'INTEGER' }
];
for (const col of requiredColumns) {
if (!existingColumns.includes(col.name)) {
console.log(`Migrating progress database schema: Adding column '${col.name}' (${col.type})`);
_db.exec(`ALTER TABLE progress ADD COLUMN ${col.name} ${col.type}`);
}
}
} catch (e) {
console.error('Failed to run schema migrations on progress table:', e.message);
}
_db.exec(` _db.exec(`
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -56,6 +78,9 @@ function loadProgress() {
attempts: r.attempts, attempts: r.attempts,
last_validated: r.last_validated, last_validated: r.last_validated,
completed_at: r.completed_at, completed_at: r.completed_at,
started_at: r.started_at || null,
notes: r.notes || null,
time_spent_seconds: r.time_spent_seconds || 0
}])); }]));
} catch { return {}; } } catch { return {}; }
} }
@@ -64,17 +89,29 @@ function saveProgress(progress) {
try { try {
const db = getDb(); const db = getDb();
const upsert = db.prepare(` const upsert = db.prepare(`
INSERT INTO progress (scenario_id, status, attempts, last_validated, completed_at) INSERT INTO progress (scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(scenario_id) DO UPDATE SET ON CONFLICT(scenario_id) DO UPDATE SET
status = excluded.status, status = excluded.status,
attempts = excluded.attempts, attempts = excluded.attempts,
last_validated = excluded.last_validated, last_validated = excluded.last_validated,
completed_at = excluded.completed_at completed_at = excluded.completed_at,
started_at = excluded.started_at,
notes = excluded.notes,
time_spent_seconds = excluded.time_spent_seconds
`); `);
const tx = db.transaction((entries) => { const tx = db.transaction((entries) => {
for (const [id, p] of entries) { for (const [id, p] of entries) {
upsert.run(id, p.status, p.attempts || 0, p.last_validated || null, p.completed_at || null); upsert.run(
id,
p.status || null,
p.attempts || 0,
p.last_validated || null,
p.completed_at || null,
p.started_at || null,
p.notes || null,
p.time_spent_seconds || 0
);
} }
}); });
tx(Object.entries(progress)); tx(Object.entries(progress));
@@ -166,20 +203,27 @@ function refreshPrompt(delayMs = 80) {
app.post('/api/progress/reset', (req, res) => { app.post('/api/progress/reset', (req, res) => {
const { scope, scenarioId, category, bundleId } = req.body; const { scope, scenarioId, category, bundleId } = req.body;
const db = getDb(); const db = getDb();
const del = db.prepare('DELETE FROM progress WHERE scenario_id = ?'); const resetProgressStmt = db.prepare(`
UPDATE progress
SET status = 'not_started',
attempts = 0,
completed_at = NULL,
last_validated = NULL
WHERE scenario_id = ?
`);
try { try {
if (scope === 'scenario') { if (scope === 'scenario') {
del.run(scenarioId); resetProgressStmt.run(scenarioId);
} else if (scope === 'category') { } else if (scope === 'category') {
const scenarios = loadScenarios(); const scenarios = loadScenarios();
const ids = scenarios.filter(s => s.category === category).map(s => s.id); const ids = scenarios.filter(s => s.category === category).map(s => s.id);
const tx = db.transaction(ids => ids.forEach(id => del.run(id))); const tx = db.transaction(ids => ids.forEach(id => resetProgressStmt.run(id)));
tx(ids); tx(ids);
} else if (scope === 'bundle') { } else if (scope === 'bundle') {
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 tx = db.transaction(ids => ids.forEach(id => del.run(id))); const tx = db.transaction(ids => ids.forEach(id => resetProgressStmt.run(id)));
tx(bundle.scenario_ids); tx(bundle.scenario_ids);
} else { } else {
return res.status(400).json({ error: 'Invalid scope' }); return res.status(400).json({ error: 'Invalid scope' });
@@ -241,15 +285,33 @@ app.post('/api/sessions/:id/submit', (req, res) => {
db.prepare(`UPDATE sessions SET status='submitted', submitted_at=datetime('now'), db.prepare(`UPDATE sessions SET status='submitted', submitted_at=datetime('now'),
duration_secs=?, snapshot=? WHERE id=?`) duration_secs=?, snapshot=? WHERE id=?`)
.run(durationSecs, JSON.stringify(snapshot), req.params.id); .run(durationSecs, JSON.stringify(snapshot), req.params.id);
// Reset timers of all scenarios in this bundle
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ?`);
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id)));
tx(bundle.scenario_ids);
res.json({ ok: true, snapshot, durationSecs }); res.json({ ok: true, snapshot, durationSecs });
}); });
// POST /api/sessions/:id/abandon — forfeit the exam without a score report // POST /api/sessions/:id/abandon — forfeit the exam without a score report
app.post('/api/sessions/:id/abandon', (req, res) => { app.post('/api/sessions/:id/abandon', (req, res) => {
const db = getDb(); const db = getDb();
const result = db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now') 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 bundles = loadBundles();
const bundle = bundles.find(b => b.id === session.bundle_id);
db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now')
WHERE id=? AND status='active'`).run(req.params.id); WHERE id=? AND status='active'`).run(req.params.id);
res.json({ ok: true, changed: result.changes });
if (bundle) {
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ?`);
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id)));
tx(bundle.scenario_ids);
}
res.json({ ok: true });
}); });
@@ -457,6 +519,33 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
}); });
}); });
// POST /api/scenarios/:id/time — update time spent on a scenario
app.post('/api/scenarios/:id/time', (req, res) => {
const { time_spent_seconds } = req.body;
if (typeof time_spent_seconds !== 'number') {
return res.status(400).json({ error: 'Invalid time_spent_seconds' });
}
const db = getDb();
const activeSession = db.prepare("SELECT 1 FROM sessions WHERE status='active'").get();
if (!activeSession) {
return res.json({ ok: true, message: 'No active session' });
}
const progress = loadProgress();
if (!progress[req.params.id]) {
progress[req.params.id] = {
status: 'in_progress',
attempts: 0,
started_at: new Date().toISOString(),
time_spent_seconds: 0
};
} else if (!progress[req.params.id].started_at) {
progress[req.params.id].started_at = new Date().toISOString();
}
progress[req.params.id].time_spent_seconds = time_spent_seconds;
saveProgress(progress);
res.json({ ok: true });
});
// GET /api/progress — full progress summary // GET /api/progress — full progress summary
app.get('/api/progress', (req, res) => { app.get('/api/progress', (req, res) => {
const scenarios = loadScenarios(); const scenarios = loadScenarios();
@@ -476,10 +565,20 @@ app.get('/api/progress', (req, res) => {
// POST /api/progress/reset/:id — reset a scenario // POST /api/progress/reset/:id — reset a scenario
app.post('/api/progress/reset/:id', (req, res) => { app.post('/api/progress/reset/:id', (req, res) => {
const progress = loadProgress(); try {
delete progress[req.params.id]; const db = getDb();
saveProgress(progress); db.prepare(`
UPDATE progress
SET status = 'not_started',
attempts = 0,
completed_at = NULL,
last_validated = NULL
WHERE scenario_id = ?
`).run(req.params.id);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
}); });
// POST /api/cache/reload — reload scenarios and bundles cache // POST /api/cache/reload — reload scenarios and bundles cache
+10 -3
View File
@@ -82,7 +82,12 @@ export default function App() {
useEffect(() => { useEffect(() => {
fetch('/api/sessions/active') fetch('/api/sessions/active')
.then(r => r.json()) .then(r => r.json())
.then(s => { if (s) setExamSession(s) }) .then(s => {
if (s) {
setExamSession(s)
setActiveBundleId(s.bundle_id)
}
})
.catch(() => { }) .catch(() => { })
}, []) }, [])
@@ -166,13 +171,15 @@ export default function App() {
const bundle = bundles.find(b => b.id === examSession.bundle_id) const bundle = bundles.find(b => b.id === examSession.bundle_id)
setExamReport({ ...result, bundle }) setExamReport({ ...result, bundle })
setExamSession(null) setExamSession(null)
}, [examSession, bundles]) refreshProgress()
}, [examSession, bundles, refreshProgress])
const abandonExam = useCallback(async () => { const abandonExam = useCallback(async () => {
if (!examSession) return if (!examSession) return
await fetch(`/api/sessions/${examSession.id}/abandon`, { method: 'POST' }).catch(() => { }) await fetch(`/api/sessions/${examSession.id}/abandon`, { method: 'POST' }).catch(() => { })
setExamSession(null) setExamSession(null)
}, [examSession]) refreshProgress()
}, [examSession, refreshProgress])
// ── Teardown when restarting a scenario (Feature 2) ─────────────────────── // ── Teardown when restarting a scenario (Feature 2) ───────────────────────
const handleScenarioStart = useCallback(async (scenarioId) => { const handleScenarioStart = useCallback(async (scenarioId) => {
+88 -1
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import styles from './ScenarioPanel.module.css' import styles from './ScenarioPanel.module.css'
@@ -34,6 +34,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [hintsRevealed, setHintsRevealed] = useState([]) const [hintsRevealed, setHintsRevealed] = useState([])
const [copiedCmd, setCopiedCmd] = useState(null) const [copiedCmd, setCopiedCmd] = useState(null)
const [localTimeSpent, setLocalTimeSpent] = useState(0)
// Reset state when scenario changes // Reset state when scenario changes
useEffect(() => { useEffect(() => {
@@ -45,6 +46,70 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
setHintsRevealed([]) setHintsRevealed([])
}, [scenario?.id]) }, [scenario?.id])
const timeSpentRef = useRef(0)
const lastScenarioIdRef = useRef(null)
// Sync ref with prop on scenario change (or when prop changes externally)
useEffect(() => {
const propTime = scenario?.progress?.time_spent_seconds || 0
if (lastScenarioIdRef.current !== scenario?.id) {
lastScenarioIdRef.current = scenario?.id
timeSpentRef.current = propTime
setLocalTimeSpent(propTime)
} else if (propTime > timeSpentRef.current) {
timeSpentRef.current = propTime
setLocalTimeSpent(propTime)
}
}, [scenario?.id, scenario?.progress?.time_spent_seconds])
const prevIsExamModeRef = useRef(isExamMode)
// Reset timer state if we exit exam mode
useEffect(() => {
if (prevIsExamModeRef.current && !isExamMode) {
timeSpentRef.current = 0
setLocalTimeSpent(0)
}
prevIsExamModeRef.current = isExamMode
}, [isExamMode])
useEffect(() => {
if (!isExamMode || !scenario || scenario.progress?.status === 'completed') return
// Begin tracking immediately: if progress database has no started_at record yet, initialize it
if (!scenario.progress?.started_at) {
fetch(`/api/scenarios/${scenario.id}/time`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ time_spent_seconds: timeSpentRef.current })
}).then(() => {
onProgressUpdate?.()
}).catch(() => {})
}
const timer = setInterval(() => {
timeSpentRef.current += 1
setLocalTimeSpent(timeSpentRef.current)
if (timeSpentRef.current % 10 === 0) {
fetch(`/api/scenarios/${scenario.id}/time`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ time_spent_seconds: timeSpentRef.current })
}).catch(() => {})
}
}, 1000)
return () => {
clearInterval(timer)
fetch(`/api/scenarios/${scenario.id}/time`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ time_spent_seconds: timeSpentRef.current }),
keepalive: true
}).catch(() => {})
}
}, [scenario?.id, scenario?.progress?.status, isExamMode, onProgressUpdate])
async function runSetup() { async function runSetup() {
setSetupState('running') setSetupState('running')
try { try {
@@ -140,6 +205,28 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
</div> </div>
<div className={styles.titleRow}> <div className={styles.titleRow}>
<div className={styles.scenarioTitle}>{scenario.title}</div> <div className={styles.scenarioTitle}>{scenario.title}</div>
{isExamMode && scenario.progress?.started_at && (
<div className={styles.progressStats}>
<div className={styles.statItem}>
<span className={styles.statLabel}>Started:</span>
<span className={styles.statVal}>
{new Date(scenario.progress.started_at).toLocaleString()}
</span>
</div>
<span className={styles.statSeparator}></span>
<div className={styles.statItem}>
<span className={styles.statLabel}>Time Spent:</span>
<span className={styles.statVal}>
{(() => {
const m = Math.floor(localTimeSpent / 60)
const s = localTimeSpent % 60
if (m > 0) return `${m}m ${s}s`
return `${s}s`
})()}
</span>
</div>
</div>
)}
{scenario.progress?.status !== 'not_started' && scenario.progress?.attempts > 0 && ( {scenario.progress?.status !== 'not_started' && scenario.progress?.attempts > 0 && (
<button <button
className={styles.resetBtn} className={styles.resetBtn}
@@ -103,7 +103,8 @@
font-weight: 700; font-weight: 700;
padding: 5px 12px; padding: 5px 12px;
cursor: pointer; cursor: pointer;
margin-top: 2px; margin-top: 0;
align-self: center;
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.2px; letter-spacing: 0.2px;
transition: color 0.12s, border-color 0.12s, background 0.12s; transition: color 0.12s, border-color 0.12s, background 0.12s;
@@ -514,3 +515,41 @@
@keyframes fadeIn { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:none} } @keyframes fadeIn { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:none} }
@keyframes spin { to{transform:rotate(360deg)} } @keyframes spin { to{transform:rotate(360deg)} }
.progressStats {
display: flex;
align-items: center;
gap: 12px;
background: var(--surface2);
border: 1px solid var(--border);
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
width: fit-content;
align-self: center;
flex-shrink: 0;
}
.statItem {
display: flex;
align-items: center;
gap: 6px;
}
.statLabel {
color: var(--text-3);
font-weight: 600;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.5px;
}
.statVal {
color: var(--text-2);
font-family: var(--mono);
}
.statSeparator {
color: var(--border2);
font-size: 10px;
}