feat(exam): add timer for each scenario in exam mode
Signed-off-by: Abhinav Sinha <[email protected]>
This commit is contained in:
+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