Authoring quizzes shipped a while ago. This slice is the other half: a Student actually taking one. A published Quiz Lesson now opens into a focused, one-question-at-a-time runner with immediate feedback, and finishing it records a Completion just like a Text or Video lesson.

The rule for v1 is deliberately strict: you must answer every question correctly to finish. There’s no score and no pass/fail — that’s reserved for a future evaluation-quiz mode. This is learning mode: get it right, learn why, move on.

One question at a time

The runner reuses the lesson chrome and curriculum sidebar, so a Quiz Lesson feels continuous with the Text and Video lessons around it. A stepper tracks progress through the questions, and — per the design — there’s no “Previous” and no “Skip”: in learning mode you move forward by answering.

First question of the quiz

Pick a wrong option and the choice is flagged, the right answer stays hidden, and you get the question’s own explanation_wrong (with a sensible default when the instructor left it blank). You can immediately try again on the same question.

Wrong answer feedback

Pick the correct one and it flashes green with the explanation_correct, and the advance button lights up.

Correct answer feedback

The last question behaves exactly like the others — it shows its feedback first — but the button reads Finish quiz instead of Next question. The Completion isn’t recorded until you click through it, so you never lose the closing explanation.

Last question with Finish quiz

Finishing records the Completion, marks the lesson done in the sidebar, and offers the way forward.

Quiz complete panel

The flow

Every correct pick stores the answer and shows feedback; the footer button is what advances — and advancing is also the moment we check whether the instructor changed the quiz underneath the student.

sequenceDiagram
    participant S as Student
    participant LV as Quiz LiveView
    participant DB as QuizAttempt

    S->>LV: pick option
    alt wrong
        LV-->>S: shake + explanation_wrong (retry)
    else correct, not last
        LV->>DB: record answer (no advance)
        LV-->>S: flash + explanation_correct, enable "Next"
        S->>LV: Next question
        LV->>DB: stale? → reset · else advance index
    else correct, last
        LV->>DB: record answer
        LV-->>S: flash + explanation_correct, enable "Finish"
        S->>LV: Finish quiz
        LV->>DB: stale? → reset · else record Completion
    end

Splitting store-on-pick from advance-on-click keeps the persisted current_question_index aligned with what’s on screen — no rewinding the view to keep feedback up — and it gives the stale check a single, natural home.

The QuizAttempt aggregate

A QuizAttempt is the student’s in-progress run through one Quiz Lesson: where they are, what they’ve answered, and a snapshot of when the quiz was last edited.

erDiagram
    USER ||--o{ QUIZATTEMPT : takes
    LESSON ||--o{ QUIZATTEMPT : for
    QUIZATTEMPT {
        uuid id
        int current_question_index
        map answers
        string status
        naive_datetime snapshot_at
    }

Two decisions are worth calling out.

A UUID primary key, from day one. Learning quizzes don’t need unguessable ids — but the coming evaluation-quiz mode (sharable review URLs, one-attempt-at-a-time integrity) will, and retrofitting a key type onto a live table is miserable. So QuizAttempt is :binary_id now, alone among otherwise-integer tables, documented in an ADR so the next reader doesn’t “simplify” it away.

Answers reference options by a stable id, not by position. Options live in a JSON array with no identity of their own, and the domain language already promises that answer order may be randomized. An answer keyed by array index would silently point at the wrong option the moment shuffling lands. So the Question changeset now mints a stable id for every option, and an attempt records question_id → option_id:

def correct?(%Question{options: options}, option_id) when is_binary(option_id) do
  case Enum.find(options || [], fn opt -> (opt["id"] || opt[:id]) == option_id end) do
    nil -> false
    opt -> opt["is_correct"] == true or opt[:is_correct] == true
  end
end

Resuming, and the stale reset

The attempt is persistent, so closing the tab and coming back drops you right where you were — same question, prior answers intact, and even the “Correct” feedback restored if you’d answered but not yet advanced.

But what if the instructor edits the quiz while a student is mid-attempt? Each attempt stores snapshot_at — the latest updated_at across the Lesson and its Questions. At the moment of advancing, the runner recomputes that value; if it’s newer than the snapshot, the attempt resets to question one with a notice:

def stale?(%__MODULE__{snapshot_at: snapshot}, %NaiveDateTime{} = current) do
  NaiveDateTime.compare(current, snapshot) == :gt
end

It’s a blunt rule — start over — but it’s honest: the quiz the student was answering no longer exists.

Review, and the empty case

Re-entering a finished quiz shows a read-only review of every question with the correct answer and its explanation — a clean recap, no inputs.

Read-only review

And a published quiz with no questions yet says so plainly, without ever creating an attempt.

Empty quiz panel

Persistence pitfalls, again

Two of this project’s recurring Ecto traps showed up here and were sidestepped on purpose:

  • Build changesets from the DB baseline. Every attempt mutation reloads the persisted record first — cast only writes a field when it differs from the struct’s current value, so mutating an in-memory copy would silently skip the write.
  • JSON maps come back with string keys. The answers map is keyed by the stringified question_id, and option lookups accept both string and atom keys, so nothing breaks at the database boundary.

What’s deferred is explicit: retaking a quiz, and the evaluation mode (scoring, pass/fail, deferred feedback, randomized order). The passing_score field already sits on the Lesson, unused, waiting for it.