From 5720b59a5fc83e3d41ce246004b41cc5823be09d Mon Sep 17 00:00:00 2001 From: Abhinav Sinha Date: Tue, 16 Jun 2026 10:53:14 +0530 Subject: [PATCH] feat(exam): add timer for each scenario in exam mode Signed-off-by: Abhinav Sinha --- backend/server.js | 129 ++++++++++++++++-- frontend/src/App.jsx | 13 +- frontend/src/components/ScenarioPanel.jsx | 89 +++++++++++- .../src/components/ScenarioPanel.module.css | 41 +++++- 4 files changed, 252 insertions(+), 20 deletions(-) diff --git a/backend/server.js b/backend/server.js index a669682..080c540 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 850d3fa..e2975d3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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) => { diff --git a/frontend/src/components/ScenarioPanel.jsx b/frontend/src/components/ScenarioPanel.jsx index bfd4d5e..b791fb1 100644 --- a/frontend/src/components/ScenarioPanel.jsx +++ b/frontend/src/components/ScenarioPanel.jsx @@ -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
{scenario.title}
+ {isExamMode && scenario.progress?.started_at && ( +
+
+ Started: + + {new Date(scenario.progress.started_at).toLocaleString()} + +
+ +
+ Time Spent: + + {(() => { + const m = Math.floor(localTimeSpent / 60) + const s = localTimeSpent % 60 + if (m > 0) return `${m}m ${s}s` + return `${s}s` + })()} + +
+
+ )} {scenario.progress?.status !== 'not_started' && scenario.progress?.attempts > 0 && (