Sessions and modes¶
Two modes, named twice¶
There are two layers, and they use different words for the same distinction.
A quiz has a mode of timed or learning. That is a property of the
thing being sat.
An attempt — one person's sitting of it — has a mode of exam or
study. That is a property of the sitting.
Starting an attempt derives one from the other unless the caller overrides it:
| Quiz mode | Default attempt mode | What it feels like |
|---|---|---|
learning |
study |
Every answer is graded the moment it is given. The status rail down the side fills in green and red as you go. The tutor may be opened. The session closes itself when every question has been answered. |
timed |
exam |
Answers are stored as DRAFT and count for nothing until you hand in. Nothing is revealed. The tutor is refused outright. |
No other quiz or attempt modes exist.
The clock¶
There is no server-side countdown. The client owns the clock and tells the server where it has got to; the server's job is to refuse to be lied to about how much is left.
Quiz.time_limit_minutes— null means no limit.- A session saved by the client carries
time_left,total_timeandsuspended. seconds_remaining()computes what is left from the last savedtime_left, falling back tototal_time - (now - started_at)for a save made before that field existed.- A suspended attempt has no running clock and never expires while suspended.
- When time is up, the attempt is auto-graded and closed exactly as a manual hand-in would — triggered when the session's progress is next fetched, or when the session list is drawn.
For a timed test generated by the builder, a limit that was not set explicitly defaults to 90 seconds a question.
Saving and resuming¶
Two Redis keys, both scoped to the learner:
| Key | Holds | TTL |
|---|---|---|
quiz_progress:{user_id}:{attempt_id} |
The ephemeral UI state — current index, chosen voice, timer | 7 days |
quiz_active:{user_id}:{attempt_id} |
Which browser session is live, from the x-quiz-session request header |
30 seconds, refreshed on every save |
The durable record is Postgres, not Redis: every answer is written to
attempt_answers the moment it is given. Fetching progress merges the Redis
copy with the database rows and the database wins. Both keys are deleted on
submit, or on an explicit clear.
The 30-second key is a marker, not a lock
Resuming from a second browser is allowed, and the newest browser takes over. It is how the interface can say "this session is open somewhere else", not a mechanism that refuses the second device.
What an attempt records¶
quiz_attempts: quiz_id, user_id, mode, score, total_questions,
started_at, completed_at, selected_question_ids (which questions this
sitting drew, for a quiz that serves a subset each time), expired.
attempt_answers, one row per question per attempt, unique on the pair:
user_answer, state, answered_at, seconds_spent (accumulated across
revisits), is_correct, used_hint.
Answering the same question again replaces the row rather than adding one — the latest pick wins.
How an answer is graded¶
Correctness is a case-insensitive, trimmed string comparison of the chosen option against the stored correct answer.
A row can be in one of three states:
| State | Meaning |
|---|---|
final |
Counts. |
draft |
Given during an exam-mode attempt and not yet handed in. |
void |
Kept and shown with a note, but counts nowhere. |
An answer is void when the question has since been trashed, or when the stored answer names an option the question no longer has. This is why a score can be stable while the bank underneath it changes.
The percentage is score ÷ answered, not score ÷ total. A question left unanswered is not counted as wrong.
What is left out of the figures¶
A quiz marked is_repetition is excluded from every performance figure — the
dashboard, the history and the analytics. Re-answering questions you have
already seen is practice, not a new measurement of what you know.
Expired attempts and attempts still in progress are excluded from the ranking the adaptive builder uses.