Finish a Quiz Lesson in Forgia and you get a Completion, a checkmark in the sidebar, and a read-only review of every question with its answer key. What you did not get was a way back in.

That made Quiz the odd one out. Word Match and Sentence Selection persist no run at all, so they have always been replayable — walk back into one and it is simply there, waiting. Quiz was the only Active Lesson type that locked after Completion, because it is the only one that persists a run. The very thing that lets a Student close the tab mid-quiz and come back to the right question is what sealed the Lesson shut once that run was over.

This slice removes the dead end. A completed Quiz now offers a Retake.

The review screen with a Retake quiz action

A Retake is practice, not a second grade

The most consequential decision here was made before a line of code: the Completion is never revoked.

Start a Retake, abandon it halfway, finish it, retake it again — the Completion sits exactly where it was. Progress never dips, the sidebar keeps its checkmark, and an issued Certificate is untouched (it is a permanent, immutable snapshot; ADR 0011 would have had something to say otherwise).

That is what makes retaking free. A Student revising before an exam should not have to weigh “do I want to review this?” against “will I lose my 100% if I close the tab?” Abandoning costs nothing, so nobody has to think about it.

The second decision was to not build an Instructor toggle for it. It is tempting — every LMS has one — but think about what it would actually gate. A practice Quiz is try-until-correct, and the review screen shows the answer key before you click Retake. A Retake can therefore only ever end one way: every answer correct. There is no failure to prevent. Attempt policy is a real concern, but it belongs to Evaluation Quizzes, which will need its own rules; there is no reason for this item to pre-build a switch on its behalf.

One row, rewound

QuizAttempt is unique(student_id, lesson_id) — one row per Student per Quiz Lesson. The obvious way to support retakes is to relax that and start collecting rows, one per run.

We didn’t. There is exactly one QuizAttempt row, forever, and a Retake rewinds it.

stateDiagram-v2
    [*] --> in_progress: first visit creates the row
    in_progress --> in_progress: answer · advance
    in_progress --> completed: Finish quiz → Completion recorded
    completed --> in_progress: Retake → same row rewound
    note right of completed
        Review screen.
        Button reads "Retake quiz".
    end note
    note left of in_progress
        On a Lesson that already has a Completion,
        this is an abandoned Retake — the button
        reads "Resume retake".
    end note

The glossary entry now says it outright: a QuizAttempt is a cursor into the current run, never an archive of past runs. That is not a limitation we are apologising for — it is the definition. Score history and “attempt 2 of 3” are not features this item declined to build; they are a different concept that would need its own table and its own reason to exist.

The payoff is that a migration was never on the table. status already carried everything the screen needs to know:

def review_action(%__MODULE__{status: "in_progress"}), do: :resume
def review_action(_attempt), do: :retake

Two clauses, no database, and the whole review-screen decision falls out of them.

The redirect that had to learn a new question

Here is the bug this item would have shipped if nobody had gone looking for it.

Finishing a Quiz ran this check:

if total > 0 and new_pct == 100 do
  push_navigate(socket, to: ~p"/catalog/#{course}/complete")

Perfectly correct for a first run. Catastrophic for a Retake. A Student who has finished an entire Course and comes back to revise one Quiz is already at 100% — so finishing the Retake would fling them into the celebration screen a second time and drag them off the Lesson they deliberately chose to revisit.

Reaching 100% was never the real question. The real question is whether this finish recorded a Completion that did not exist before:

def course_just_completed?(false = _newly_completed?, _total_lessons, _percentage), do: false

def course_just_completed?(true, total_lessons, percentage) when total_lessons > 0,
  do: percentage == 100

def course_just_completed?(true, _total_lessons, _percentage), do: false
flowchart TD
    A[Finish quiz] --> B[Read completed_ids
BEFORE recording] B --> C[record_completion_and_progress] C --> D{Did this Lesson
already have a Completion?} D -->|yes — a Retake| E[Stay put:
Quiz complete panel] D -->|no| F{Progress now 100%?} F -->|yes| G[Course-complete screen] F -->|no| E

Order matters: completed_ids has to be read before the Completion is recorded, or the answer is always “yes, it was already there.”

So a Retake finished at an unchanged 100% now ends where it should — on the Quiz complete panel, Course still complete, nobody moved anywhere:

Finishing a Retake at 100% stays on the Quiz complete panel

Worth stressing the other half: on a genuine first finish the redirect still fires. That is the case a change like this quietly breaks, so it is asserted both in the test suite and in the browser run.

Resume retake

What about a Retake abandoned halfway? Two answers in, the Student wanders off to another Lesson and comes back.

The rule is one line long: landing on a completed Quiz is always the review screen. No invisible history, no “were you in the middle of something?” guesswork. Only the button changes.

The review screen offering Resume retake

Click it and you are back on the question you left, with the earlier answers still marked in the stepper:

Resuming returns to question three with two answers intact

No half-finished run is silently discarded, and the schema did not move an inch — status was already telling us which of the two states we were in.

A fresh snapshot, for free

A QuizAttempt carries snapshot_at: the latest updated_at across the Lesson and its Questions. If the Instructor edits the Quiz mid-run, advancing detects the drift and resets the attempt with a “the quiz was updated — let’s start over” notice.

That mechanism could have become a nuisance here. Imagine completing a Quiz in June, the Instructor rewording a question in July, and the Student retaking it in August: the attempt’s snapshot is two months stale, so the first click of the Retake would fire the reset flash on question one — restarting a run that had barely started.

It doesn’t, because the rewind takes a new snapshot as part of the Retake:

def retake_attempt(%QuizAttempt{id: id}, snapshot_at) do
  get_attempt!(id)
  |> QuizAttempt.changeset(%{
    current_question_index: 0,
    answers: %{},
    snapshot_at: snapshot_at,
    status: "in_progress"
  })
  |> Repo.update!()
end

A Retake always runs against the Quiz as it is now. An edit made during a Retake still trips the existing staleness path, untouched — which is exactly right, because then the Quiz really did change underneath the Student.

This is deliberately a separate function from the reset_attempt/2 that the staleness path uses. They differ by one field: reset_attempt/2 restarts a run that is still in_progress and so has no status to restore. Collapsing them into one function with a flag would save four lines and cost the next reader the distinction.

What a Retake leaves behind

Challenge Attempts — one row per pick, append-only (ADR 0017) — are still recorded during a Retake. Every pick, correct ones included, on exactly the same code path as a first run. No flag, no suppression, no branch.

That raises a fair question for Analytics: a Student practising a Quiz for the fourth time should not read as a Student who is struggling with it. How do we tell practice from a genuine first attempt?

We already can. Both challenge_attempts and completions hang off enrollment_id + lesson_id and carry inserted_at. “Written after this Lesson’s Completion” is the Retake discriminator — derivable today, with zero schema change:

erDiagram
    ENROLLMENT ||--o{ COMPLETION : earns
    ENROLLMENT ||--o{ CHALLENGE_ATTEMPT : logs
    LESSON ||--o{ COMPLETION : for
    LESSON ||--o{ CHALLENGE_ATTEMPT : for
    COMPLETION {
        int enrollment_id
        int lesson_id
        utc_datetime inserted_at "the discriminator"
    }
    CHALLENGE_ATTEMPT {
        int enrollment_id
        int lesson_id
        string challenge_id
        boolean correct
        utc_datetime inserted_at "after the Completion → Retake work"
    }

A boolean retake column was the obvious alternative and was rejected: it would record what the timestamp already proves. Not recording at all was worse — it discards data permanently, hides a Student who genuinely struggles on a Retake, and would make Quiz behave unlike its siblings for no reason a Student could perceive.

This item only guarantees the signal is derivable. Students who are Struggling and Lesson Drop-off Funnel get to decide what to do with it.

Where the button goes

There is no design-system prototype for the review screen — lesson-quiz.html covers the taking state only. Per the house rule, that means adapting the pattern that already exists rather than inventing one: the .quiz-panel-actions row and the existing mm-btn variants.

Retake is mm-btn-ghost, yielding the primary slot to Next lesson, which is still the forward motion a Student most often wants. Except on the Course’s last Lesson — there is no “Next lesson” there, and a row of two ghost buttons has no centre of gravity at all. So on the last Lesson, Retake takes the primary slot:

On the Course’s last Lesson, Retake is the primary action

And when the Instructor has deleted every Question, the empty-quiz screen is evaluated before the completed branch — so the review screen, and with it the Retake button, is simply not reachable. No special case needed.

Testing what is testable, and admitting what isn’t

Two of the rules here are pure functions and are tested as such, with no database in sight: the review-screen decision (review_action/1) and the redirect rule (course_just_completed?/3). Between them they carry the logic most likely to be wrong.

The rest genuinely cannot be asserted without Postgres — that the Completion survives, that one row is rewound rather than a second inserted, that Challenge Attempts are still written and still distinguishable. Our Definition of Done permits integration coverage exactly when the acceptance criteria name such behaviour explicitly, so they were named explicitly during refinement, before anyone wrote a test.

One detail from that suite is worth repeating, because it bit the first draft. inserted_at is second-granular, so a test that completes a Quiz and retakes it in the same millisecond cannot tell the two runs apart — a strict “after the Completion” check either passes for the wrong reason or fails for one. The fix is to simulate elapsed time honestly: shift the entire first run back, its Challenge Attempts and its Completion together, preserving their order. Backdating only the Completion silently drags the first run’s picks to the wrong side of the line, which is precisely the false pass the check exists to catch.

The full flow was then driven in a real browser — golden path, abandon-and-resume, and a Student retaking a Quiz in a Course they had already finished — including that the Retake is reachable by Tab and fires on Enter. The screenshots above are from that run.