Skip to content

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:

chosen_mode = mode or ("study" if quiz.mode == "learning" else "exam")
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_time and suspended.
  • seconds_remaining() computes what is left from the last saved time_left, falling back to total_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.