feat(exam): add timer for each scenario in exam mode
Signed-off-by: Abhinav Sinha <[email protected]>
This commit is contained in:
+114
-15
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync, exec } = require('child_process');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -33,6 +33,28 @@ function getDb() {
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -56,6 +78,9 @@ function loadProgress() {
|
||||
attempts: r.attempts,
|
||||
last_validated: r.last_validated,
|
||||
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 {}; }
|
||||
}
|
||||
@@ -64,17 +89,29 @@ function saveProgress(progress) {
|
||||
try {
|
||||
const db = getDb();
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO progress (scenario_id, status, attempts, last_validated, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO progress (scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(scenario_id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
attempts = excluded.attempts,
|
||||
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) => {
|
||||
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));
|
||||
@@ -166,20 +203,27 @@ function refreshPrompt(delayMs = 80) {
|
||||
app.post('/api/progress/reset', (req, res) => {
|
||||
const { scope, scenarioId, category, bundleId } = req.body;
|
||||
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 {
|
||||
if (scope === 'scenario') {
|
||||
del.run(scenarioId);
|
||||
resetProgressStmt.run(scenarioId);
|
||||
} else if (scope === 'category') {
|
||||
const scenarios = loadScenarios();
|
||||
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);
|
||||
} else if (scope === 'bundle') {
|
||||
const bundle = loadBundles().find(b => b.id === bundleId);
|
||||
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);
|
||||
} else {
|
||||
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'),
|
||||
duration_secs=?, snapshot=? WHERE 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 });
|
||||
});
|
||||
|
||||
// POST /api/sessions/:id/abandon — forfeit the exam without a score report
|
||||
app.post('/api/sessions/:id/abandon', (req, res) => {
|
||||
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);
|
||||
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
|
||||
app.get('/api/progress', (req, res) => {
|
||||
const scenarios = loadScenarios();
|
||||
@@ -476,10 +565,20 @@ app.get('/api/progress', (req, res) => {
|
||||
|
||||
// POST /api/progress/reset/:id — reset a scenario
|
||||
app.post('/api/progress/reset/:id', (req, res) => {
|
||||
const progress = loadProgress();
|
||||
delete progress[req.params.id];
|
||||
saveProgress(progress);
|
||||
res.json({ ok: true });
|
||||
try {
|
||||
const db = getDb();
|
||||
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 });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cache/reload — reload scenarios and bundles cache
|
||||
|
||||
+10
-3
@@ -82,7 +82,12 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
fetch('/api/sessions/active')
|
||||
.then(r => r.json())
|
||||
.then(s => { if (s) setExamSession(s) })
|
||||
.then(s => {
|
||||
if (s) {
|
||||
setExamSession(s)
|
||||
setActiveBundleId(s.bundle_id)
|
||||
}
|
||||
})
|
||||
.catch(() => { })
|
||||
}, [])
|
||||
|
||||
@@ -166,13 +171,15 @@ export default function App() {
|
||||
const bundle = bundles.find(b => b.id === examSession.bundle_id)
|
||||
setExamReport({ ...result, bundle })
|
||||
setExamSession(null)
|
||||
}, [examSession, bundles])
|
||||
refreshProgress()
|
||||
}, [examSession, bundles, refreshProgress])
|
||||
|
||||
const abandonExam = useCallback(async () => {
|
||||
if (!examSession) return
|
||||
await fetch(`/api/sessions/${examSession.id}/abandon`, { method: 'POST' }).catch(() => { })
|
||||
setExamSession(null)
|
||||
}, [examSession])
|
||||
refreshProgress()
|
||||
}, [examSession, refreshProgress])
|
||||
|
||||
// ── Teardown when restarting a scenario (Feature 2) ───────────────────────
|
||||
const handleScenarioStart = useCallback(async (scenarioId) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import styles from './ScenarioPanel.module.css'
|
||||
@@ -34,6 +34,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [hintsRevealed, setHintsRevealed] = useState([])
|
||||
const [copiedCmd, setCopiedCmd] = useState(null)
|
||||
const [localTimeSpent, setLocalTimeSpent] = useState(0)
|
||||
|
||||
// Reset state when scenario changes
|
||||
useEffect(() => {
|
||||
@@ -45,6 +46,70 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
setHintsRevealed([])
|
||||
}, [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() {
|
||||
setSetupState('running')
|
||||
try {
|
||||
@@ -140,6 +205,28 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
</div>
|
||||
<div className={styles.titleRow}>
|
||||
<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 && (
|
||||
<button
|
||||
className={styles.resetBtn}
|
||||
|
||||
@@ -103,7 +103,8 @@
|
||||
font-weight: 700;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
margin-top: 2px;
|
||||
margin-top: 0;
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.2px;
|
||||
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 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user