diff --git a/backend/app/services/attempt_expiry.py b/backend/app/services/attempt_expiry.py index 22b3b81..29854b4 100644 --- a/backend/app/services/attempt_expiry.py +++ b/backend/app/services/attempt_expiry.py @@ -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: diff --git a/frontend/src/hooks/useAwayDetector.js b/frontend/src/hooks/useAwayDetector.js new file mode 100644 index 0000000..77cfd9f --- /dev/null +++ b/frontend/src/hooks/useAwayDetector.js @@ -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 } +} diff --git a/frontend/src/hooks/useAwayDetector.test.js b/frontend/src/hooks/useAwayDetector.test.js new file mode 100644 index 0000000..abba12e --- /dev/null +++ b/frontend/src/hooks/useAwayDetector.test.js @@ -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) + }) +}) diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 2551338..a4bdda6 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -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
{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.'}
+ {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.'} +
+ +