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
+10 -3
View File
@@ -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) => {
+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 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;
}