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.

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.

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

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.

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

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.

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

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 —
castonly 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
answersmap is keyed by the stringifiedquestion_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.