fix: the exam clock runs only while somebody is sitting the exam
It ran on a wall clock. An hour away from the tab spent an hour of the exam on questions that were never shown, and every per-question figure was a fiction — which is the number the whole analysis is built on. Three things stop it now. The tab being hidden, which catches switching away. An explicit pause. And, for the commonest case the other two miss — the tab left open on the exam while the person is in another room — an idle watch: three minutes with no mousemove, key, wheel, touch or scroll and it asks "Still there?", with the clock already stopped by the time the question appears. A stray pointer movement does not answer it; somebody has to say they are there. Three minutes, not one, and scrolling counts as activity: reading a long vignette is minutes without a click, and interrupting genuine reading to ask whether you are reading is worse than occasionally crediting a minute nobody was there for. The server was the other half. seconds_remaining computed from started_at and total_time, so a paused client made no difference to what the server thought was left. It reads the saved time_left now, which is what the player decrements only while the exam is on screen, falling back to the wall clock for progress saved before this existed. And a five-minute warning, said once. An exam that ends without notice is a scramble; one that nags is a distraction. Reverts the exam-exit-submits rule from earlier in this branch, which was built on the opposite premise and would have charged wall-clock time and then graded an exam whose clock should simply have stopped. Leaving suspends, in both modes, and the overview no longer promises a clock that does not stop for a break. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
c353373231
commit
6860750770
6 changed files with 293 additions and 49 deletions
|
|
@ -33,11 +33,25 @@ def active_key(user_id: int, attempt_id: int) -> str:
|
|||
def seconds_remaining(saved: dict) -> float | None:
|
||||
"""Time left on an unsuspended timed attempt, or None when there is no clock.
|
||||
|
||||
An exam's clock runs only while the exam is on screen, so what is left is
|
||||
what the player last saved — not what a wall clock would have spent. This
|
||||
used to be computed from `started_at` and `total_time`, which charged a
|
||||
learner for an hour away from the tab as though they had been sitting the
|
||||
paper, and made every per-question timing a fiction.
|
||||
|
||||
A suspended attempt holds its `time_left` and has no running clock, so it
|
||||
never expires while suspended.
|
||||
"""
|
||||
if not saved or saved.get("suspended"):
|
||||
return None
|
||||
left = saved.get("time_left")
|
||||
if left is not None:
|
||||
try:
|
||||
return max(0.0, float(left))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
# Older saved progress carries no `time_left`; fall back to the wall clock
|
||||
# rather than treating a timed attempt as untimed.
|
||||
total = saved.get("total_time")
|
||||
started = saved.get("started_at")
|
||||
if total is None or not started:
|
||||
|
|
@ -61,6 +75,17 @@ def settle_if_expired(db: Session, redis_client, user_id: int, attempt: QuizAtte
|
|||
remaining = seconds_remaining(saved)
|
||||
if remaining is None or remaining > 0:
|
||||
return False
|
||||
return _submit(db, redis_client, user_id, attempt, saved)
|
||||
|
||||
|
||||
def _submit(db: Session, redis_client, user_id: int, attempt: QuizAttempt, saved: dict) -> bool:
|
||||
"""Grade and close one attempt from its saved progress.
|
||||
|
||||
Shared by every path that ends an attempt without the learner pressing
|
||||
submit, so a clock running out, a tab closing and a manual submission
|
||||
cannot disagree about a score. Serialised against a concurrent manual
|
||||
submit by re-reading the attempt under lock.
|
||||
"""
|
||||
try:
|
||||
db.refresh(attempt, with_for_update=True)
|
||||
if attempt.completed_at:
|
||||
|
|
|
|||
72
frontend/src/hooks/useAwayDetector.js
Normal file
72
frontend/src/hooks/useAwayDetector.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Is anybody there?
|
||||
*
|
||||
* The clock stopping when the tab is hidden catches someone who switched away.
|
||||
* It does not catch the commoner thing: the tab left open on the exam while
|
||||
* the person is in another room. This watches for signs of life and, after a
|
||||
* stretch without any, asks — and stops the clock until it is answered.
|
||||
*
|
||||
* The threshold is generous on purpose. Reading a long vignette is two or three
|
||||
* minutes of no input at all, and an interface that interrupts genuine reading
|
||||
* to ask whether you are reading is worse than one that occasionally credits a
|
||||
* minute nobody was there for. Scrolling and moving the pointer count as
|
||||
* activity precisely so that reading registers as reading.
|
||||
*/
|
||||
|
||||
//: Signs of life. Passive, so none of them cost anything on a scroll.
|
||||
const SIGNS = ['mousemove', 'mousedown', 'keydown', 'wheel', 'touchstart', 'scroll']
|
||||
|
||||
export const AWAY_AFTER = 180_000 // 3 minutes of nothing at all
|
||||
export const ANSWER_WITHIN = 60_000 // then a minute to say you are there
|
||||
|
||||
export default function useAwayDetector({ enabled = true, awayAfter = AWAY_AFTER } = {}) {
|
||||
const [asking, setAsking] = useState(false)
|
||||
const [away, setAway] = useState(false)
|
||||
const timer = useRef(null)
|
||||
const lastSeen = useRef(Date.now())
|
||||
|
||||
const arm = useCallback(() => {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => setAsking(true), awayAfter)
|
||||
}, [awayAfter])
|
||||
|
||||
const confirmHere = useCallback(() => {
|
||||
lastSeen.current = Date.now()
|
||||
setAsking(false)
|
||||
setAway(false)
|
||||
arm()
|
||||
}, [arm])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
clearTimeout(timer.current)
|
||||
setAsking(false)
|
||||
setAway(false)
|
||||
return undefined
|
||||
}
|
||||
const seen = () => {
|
||||
lastSeen.current = Date.now()
|
||||
// While the question is standing, activity does not answer it: a stray
|
||||
// pointer movement is not somebody saying they are at the desk.
|
||||
if (!asking) arm()
|
||||
}
|
||||
SIGNS.forEach(sign => window.addEventListener(sign, seen, { passive: true }))
|
||||
arm()
|
||||
return () => {
|
||||
SIGNS.forEach(sign => window.removeEventListener(sign, seen))
|
||||
clearTimeout(timer.current)
|
||||
}
|
||||
}, [enabled, asking, arm])
|
||||
|
||||
// Unanswered for a minute: they are not there, and the clock stays stopped
|
||||
// until they come back and say so.
|
||||
useEffect(() => {
|
||||
if (!asking) return undefined
|
||||
const giveUp = setTimeout(() => setAway(true), ANSWER_WITHIN)
|
||||
return () => clearTimeout(giveUp)
|
||||
}, [asking])
|
||||
|
||||
return { asking, away, confirmHere, lastSeen }
|
||||
}
|
||||
57
frontend/src/hooks/useAwayDetector.test.js
Normal file
57
frontend/src/hooks/useAwayDetector.test.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import useAwayDetector from './useAwayDetector'
|
||||
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('away detector', () => {
|
||||
it('says nothing while there are signs of life', () => {
|
||||
const { result } = renderHook(() => useAwayDetector({ awayAfter: 1000 }))
|
||||
act(() => { vi.advanceTimersByTime(800) })
|
||||
act(() => { window.dispatchEvent(new Event('mousemove')) })
|
||||
act(() => { vi.advanceTimersByTime(800) })
|
||||
// Reading a long vignette is minutes of no clicking; moving the pointer
|
||||
// and scrolling count, precisely so that reading registers as reading.
|
||||
expect(result.current.asking).toBe(false)
|
||||
})
|
||||
|
||||
it('asks once nothing has happened for the whole stretch', () => {
|
||||
const { result } = renderHook(() => useAwayDetector({ awayAfter: 1000 }))
|
||||
act(() => { vi.advanceTimersByTime(1001) })
|
||||
expect(result.current.asking).toBe(true)
|
||||
expect(result.current.away).toBe(false)
|
||||
})
|
||||
|
||||
it('does not take a stray pointer movement as an answer', () => {
|
||||
const { result } = renderHook(() => useAwayDetector({ awayAfter: 1000 }))
|
||||
act(() => { vi.advanceTimersByTime(1001) })
|
||||
act(() => { window.dispatchEvent(new Event('mousemove')) })
|
||||
// Somebody has to say they are there. A cat on the desk does not.
|
||||
expect(result.current.asking).toBe(true)
|
||||
})
|
||||
|
||||
it('concludes they are gone when the question goes unanswered', () => {
|
||||
const { result } = renderHook(() => useAwayDetector({ awayAfter: 1000 }))
|
||||
act(() => { vi.advanceTimersByTime(1001) })
|
||||
act(() => { vi.advanceTimersByTime(60_001) })
|
||||
expect(result.current.away).toBe(true)
|
||||
})
|
||||
|
||||
it('starts again when they say they are here', () => {
|
||||
const { result } = renderHook(() => useAwayDetector({ awayAfter: 1000 }))
|
||||
act(() => { vi.advanceTimersByTime(1001) })
|
||||
act(() => { result.current.confirmHere() })
|
||||
expect(result.current.asking).toBe(false)
|
||||
expect(result.current.away).toBe(false)
|
||||
// And the watch is rearmed rather than spent.
|
||||
act(() => { vi.advanceTimersByTime(1001) })
|
||||
expect(result.current.asking).toBe(true)
|
||||
})
|
||||
|
||||
it('watches nothing when no session is being sat', () => {
|
||||
const { result } = renderHook(() => useAwayDetector({ enabled: false, awayAfter: 1000 }))
|
||||
act(() => { vi.advanceTimersByTime(60_000) })
|
||||
expect(result.current.asking).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -8,6 +8,7 @@ import { mergeTextRanges } from '../utils/highlightOffsets'
|
|||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
import useMediaQuery from '../hooks/useMediaQuery'
|
||||
import useAwayDetector from '../hooks/useAwayDetector'
|
||||
import FigureStrip from '../components/FigureStrip'
|
||||
import FeedbackForm from '../components/FeedbackForm'
|
||||
import ShareSession from '../components/ShareSession'
|
||||
|
|
@ -524,39 +525,6 @@ export default function QuizPage() {
|
|||
|
||||
const exitTarget = () => returnTo || (attemptId ? `/sessions/${attemptId}` : '/')
|
||||
|
||||
/**
|
||||
* Leave, saving on the way out.
|
||||
*
|
||||
* This used to ask "Suspend quiz?" and offer Stay. Nobody presses Exit by
|
||||
* accident, nothing is lost — the answers are saved and the clock pauses,
|
||||
* which is what the dialog was explaining rather than deciding — and a
|
||||
* confirmation for a reversible act is a step, not a safeguard. It says
|
||||
* what happened afterwards instead.
|
||||
*/
|
||||
const leaveNow = useCallback(async () => {
|
||||
if (attemptId && quizMode) {
|
||||
try {
|
||||
await api.post('/attempts/progress', {
|
||||
quiz_id: parseInt(id),
|
||||
attempt_id: attemptId,
|
||||
answers,
|
||||
current_idx: currentIdx,
|
||||
mode: quizMode,
|
||||
voice: selectedVoice || null,
|
||||
time_left: timeLeft,
|
||||
started_at: startedAt,
|
||||
total_time: totalTime,
|
||||
suspended: true,
|
||||
}, { headers: { 'x-quiz-session': SESSION_ID } })
|
||||
} catch {
|
||||
// Staying put is the safe failure: leaving now would lose the answers.
|
||||
setProgressError('Could not save before leaving. Keep this tab open and retry saving.')
|
||||
return
|
||||
}
|
||||
}
|
||||
navigate(exitTarget())
|
||||
}, [attemptId, quizMode, id, answers, currentIdx, selectedVoice, timeLeft,
|
||||
startedAt, totalTime, navigate, returnTo])
|
||||
const questions = quiz?.questions || []
|
||||
// ?q=3 means "open on question 3". The analytics table links here that way:
|
||||
// clicking a row in a session you have not finished should put you on that
|
||||
|
|
@ -573,6 +541,12 @@ export default function QuizPage() {
|
|||
const current = questions[currentIdx]
|
||||
const isStudy = quizMode === 'study'
|
||||
|
||||
// Away from the desk is not time spent on the question, whether or not the
|
||||
// tab is still in front. Only while a session is actually being sat.
|
||||
const { asking: askingStillHere, away, confirmHere } =
|
||||
useAwayDetector({ enabled: !!attemptId && !!quizMode })
|
||||
const clockStopped = clockPaused || askingStillHere || away
|
||||
|
||||
const applyManualHighlightSelection = useCallback((selected = getManualHighlightSelection() || savedHighlightSelectionRef.current) => {
|
||||
if (!selected || !current) return
|
||||
const [questionKey, fieldKey] = selected.id.split('::')
|
||||
|
|
@ -829,13 +803,40 @@ export default function QuizPage() {
|
|||
}, [currentIdx])
|
||||
|
||||
const timerStarted = timeLeft !== null
|
||||
/**
|
||||
* The exam clock runs only while the exam is on screen.
|
||||
*
|
||||
* It used to tick on a wall clock, so an hour away from the tab spent an
|
||||
* hour of the exam on questions you were never shown. Time you were not
|
||||
* given the questions for is not time you used — and it is what makes the
|
||||
* per-question figures mean anything at all.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!timerStarted) return
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft(t => { if (t <= 1) { clearInterval(timerRef.current); return 0 } return t - 1 })
|
||||
}, 1000)
|
||||
return () => clearInterval(timerRef.current)
|
||||
}, [timerStarted])
|
||||
if (!timerStarted) return undefined
|
||||
const start = () => {
|
||||
clearInterval(timerRef.current)
|
||||
if (document.hidden || clockStopped) return
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft(t => { if (t <= 1) { clearInterval(timerRef.current); return 0 } return t - 1 })
|
||||
}, 1000)
|
||||
}
|
||||
start()
|
||||
document.addEventListener('visibilitychange', start)
|
||||
return () => {
|
||||
clearInterval(timerRef.current)
|
||||
document.removeEventListener('visibilitychange', start)
|
||||
}
|
||||
}, [timerStarted, clockStopped])
|
||||
|
||||
// A warning with time to act on it. Said once, at five minutes: an exam that
|
||||
// ends without notice is a scramble, and one that nags is a distraction.
|
||||
const warnedAt = useRef(null)
|
||||
useEffect(() => {
|
||||
if (timeLeft === null || warnedAt.current) return
|
||||
if (timeLeft > 300 || timeLeft <= 0) return
|
||||
warnedAt.current = true
|
||||
showToast('Five minutes left in this block.')
|
||||
}, [timeLeft])
|
||||
|
||||
// Auto-submit when the timer expires during an active session — never right after resume.
|
||||
useEffect(() => {
|
||||
|
|
@ -881,14 +882,26 @@ const timerStarted = timeLeft !== null
|
|||
}
|
||||
}, [attemptId, quizMode, saveProgressNow])
|
||||
|
||||
// Same rule for the session and per-question clocks: away from the screen is
|
||||
// not time spent on the question.
|
||||
useEffect(() => {
|
||||
if (clockPaused || !attemptId) return
|
||||
const tick = setInterval(() => {
|
||||
setSessionSeconds(v => v + 1)
|
||||
setQuestionSeconds(v => v + 1)
|
||||
}, 1000)
|
||||
return () => clearInterval(tick)
|
||||
}, [clockPaused, attemptId])
|
||||
if (clockStopped || !attemptId) return undefined
|
||||
let tick = null
|
||||
const start = () => {
|
||||
clearInterval(tick)
|
||||
if (document.hidden) return
|
||||
tick = setInterval(() => {
|
||||
setSessionSeconds(v => v + 1)
|
||||
setQuestionSeconds(v => v + 1)
|
||||
}, 1000)
|
||||
}
|
||||
start()
|
||||
document.addEventListener('visibilitychange', start)
|
||||
return () => {
|
||||
clearInterval(tick)
|
||||
document.removeEventListener('visibilitychange', start)
|
||||
}
|
||||
}, [clockStopped, attemptId])
|
||||
|
||||
// Bank the time on the question you are leaving, then start the next at zero.
|
||||
const leavingRef = useRef({ id: null, seconds: 0 })
|
||||
|
|
@ -1039,6 +1052,44 @@ const timerStarted = timeLeft !== null
|
|||
} finally { setSubmitting(false) }
|
||||
}, [attemptId, answers, submitting, navigate, showToast])
|
||||
|
||||
/**
|
||||
* Leave.
|
||||
*
|
||||
* In study mode that means suspending: the answers are saved, the clock
|
||||
* pauses, and you pick it up where you left off.
|
||||
*
|
||||
* Exam mode suspends too. The clock only runs while the exam is on screen,
|
||||
* so leaving stops it rather than spending it — an exam you are not looking
|
||||
* at is not an exam you are sitting, and time you were not given the
|
||||
* questions for is not time you used.
|
||||
*
|
||||
* Either way it is one press. Nobody leaves by accident, and nothing is lost.
|
||||
*/
|
||||
const leaveNow = useCallback(async () => {
|
||||
if (attemptId && quizMode) {
|
||||
try {
|
||||
await api.post('/attempts/progress', {
|
||||
quiz_id: parseInt(id),
|
||||
attempt_id: attemptId,
|
||||
answers,
|
||||
current_idx: currentIdx,
|
||||
mode: quizMode,
|
||||
voice: selectedVoice || null,
|
||||
time_left: timeLeft,
|
||||
started_at: startedAt,
|
||||
total_time: totalTime,
|
||||
suspended: true,
|
||||
}, { headers: { 'x-quiz-session': SESSION_ID } })
|
||||
} catch {
|
||||
// Staying put is the safe failure: leaving now would lose the answers.
|
||||
setProgressError('Could not save before leaving. Keep this tab open and retry saving.')
|
||||
return
|
||||
}
|
||||
}
|
||||
navigate(exitTarget())
|
||||
}, [attemptId, quizMode, isStudy, id, answers, currentIdx, selectedVoice, timeLeft,
|
||||
startedAt, totalTime, navigate, returnTo, handleSubmit])
|
||||
|
||||
useEffect(() => {
|
||||
if (!quizMode || !current) return
|
||||
const keydown = event => {
|
||||
|
|
@ -1090,7 +1141,7 @@ const timerStarted = timeLeft !== null
|
|||
</p>
|
||||
<p className="qz-overview-note">
|
||||
{quiz.mode === 'timed'
|
||||
? 'The clock starts when you begin and does not stop for a break.'
|
||||
? 'The clock runs only while the exam is on screen — leave it and it stops.'
|
||||
: 'Each answer is marked as you go, with the explanation.'}
|
||||
</p>
|
||||
<div className="qz-overview-actions">
|
||||
|
|
@ -1225,6 +1276,23 @@ const timerStarted = timeLeft !== null
|
|||
</QuizDialog>}
|
||||
{submitError && <div role="alert" className="quiz-submit-error">{submitError} <button type="button" disabled={submitting} onClick={() => handleSubmit(false)}>Retry submission</button></div>}
|
||||
{progressError && <div role="alert" className="quiz-submit-error">{progressError} <button type="button" onClick={() => saveProgressNow()}>Retry saving</button></div>}
|
||||
{/* Still there? The clock is already stopped by the time this shows —
|
||||
it is not a threat, it is how the time stays honest. */}
|
||||
{(askingStillHere || away) && (
|
||||
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="away-heading">
|
||||
<div className="quiz-away-card">
|
||||
<h2 id="away-heading">Still there?</h2>
|
||||
<p>
|
||||
{away
|
||||
? 'The clock has been stopped since you stopped. Nothing is lost — pick up where you left off.'
|
||||
: 'Nothing has happened for a few minutes, so the clock is stopped. Time you were not at the desk for is not time you spent on the question.'}
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary" onClick={confirmHere}>
|
||||
I'm here — carry on
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{toast && (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
|
||||
|
|
@ -1256,7 +1324,8 @@ const timerStarted = timeLeft !== null
|
|||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => leaveNow()} title="Save progress and exit">
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => leaveNow()}
|
||||
title="Save your answers and pick this up later — the clock stops too">
|
||||
⏸ Suspend
|
||||
</button>
|
||||
{restartConfirm ? (
|
||||
|
|
|
|||
|
|
@ -222,12 +222,17 @@ describe('quiz player', () => {
|
|||
})
|
||||
|
||||
it('stays on the quiz when suspension cannot save and supports retry', async () => {
|
||||
await begin(false)
|
||||
// Study mode, because that is where leaving means suspending. An exam
|
||||
// that is left is submitted instead.
|
||||
await begin()
|
||||
api.post.mockClear()
|
||||
const originalPost = api.post.getMockImplementation()
|
||||
let failSaving = true
|
||||
api.post.mockImplementation((url, ...args) => url === '/attempts/progress' && failSaving ? Promise.reject(new Error('Cache outage')) : originalPost(url, ...args))
|
||||
fireEvent.keyDown(window, { key: '1' })
|
||||
// Study mode holds a selection until it is confirmed, so confirm it — the
|
||||
// point of this test is what is saved, not what is provisionally picked.
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
await userEvent.click(screen.getByRole('button', { name: '⏸ Suspend' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Keep this tab open')
|
||||
expect(inCard().getByText('Full first clinical question.')).toBeInTheDocument()
|
||||
|
|
|
|||
|
|
@ -332,3 +332,19 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
|
|||
.quiz-footbar { gap: 8px; }
|
||||
.quiz-footbar .quiz-nav-controls { order: -1; width: 100%; }
|
||||
}
|
||||
|
||||
/* ── Still there? ─────────────────────────────────────────────────────
|
||||
Shown after a stretch with no sign of life. The clock is already stopped
|
||||
by the time this appears, so it is an explanation rather than a warning. */
|
||||
.quiz-away {
|
||||
position: fixed; inset: 0; z-index: 1300;
|
||||
display: flex; align-items: center; justify-content: center; padding: 20px;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
.quiz-away-card {
|
||||
width: min(400px, 100%); padding: 28px 26px; text-align: center;
|
||||
background: var(--card-bg); border-radius: 14px;
|
||||
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
.quiz-away-card h2 { margin: 0 0 10px; font-size: 1.15rem; }
|
||||
.quiz-away-card p { margin: 0 0 20px; font-size: 0.9rem; line-height: 1.6; color: var(--text-muted); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue