# Timed quizzes — frontend guide

`quiz_duration_minutes` already existed on a quiz lesson and was already
returned to the client, but nothing enforced it — a learner could sit on an
open attempt indefinitely and still have it graded. The backend now enforces
the deadline server-side. This doc covers what changed and how the frontend
should use it; no new endpoints were added.

## Endpoints covered

| Endpoint | Auth | Purpose |
|---|---|---|
| `POST /api/v1/user/lessons/{lesson}/quiz/start` | Bearer (user) | Open (or resume) a quiz attempt |
| `GET /api/v1/user/lessons/{lesson}/quiz/current-attempt` | Bearer (user) | Read the learner's open attempt, if any, without opening one |
| `POST /api/v1/user/lessons/{lesson}/quiz-attempts` | Bearer (user) | Submit an attempt for auto-grading |

All three already existed; only their behavior and response shape changed.

---

## What's new

### `expires_at`

`start` and `current-attempt` now return an `expires_at` field alongside the
existing `started_at`/`quiz_duration_minutes`, so the client doesn't have to
do the date math itself:

```json
{
  "status": "success",
  "message": "Quiz started successfully",
  "data": {
    "attempt": {
      "id": "0199...uuid",
      "attempt_number": 1,
      "started_at": "2026-09-24T10:00:00+00:00",
      "quiz_duration_minutes": 15,
      "expires_at": "2026-09-24T10:15:00+00:00"
    },
    "max_attempts": 3,
    "attempts_used": 1,
    "attempts_remaining": 2,
    "questions": [ "..." ]
  }
}
```

`expires_at` is `null` when `quiz_duration_minutes` is `null` — the quiz is
untimed and there is no deadline to render a countdown against.

### Server-side enforcement

Once `now()` passes `expires_at`, the attempt is no longer usable:

- **`current-attempt`** stops returning it — the response looks exactly like
  "nothing in progress" (`data.attempt: null`, `data.questions: []`), same as
  before anything was started.
- **A late `quiz-attempts` submit** is rejected the same way an
  already-submitted attempt is — `422` with a business-rule error, not a
  score. There is no separate "expired" error code; treat it identically to
  the existing "This quiz attempt has already been submitted" case.
- **Starting again** opens a fresh attempt (attempt #2, #3, …) as long as
  `max_attempts` isn't exhausted — the expired attempt still counts as one
  used attempt, exactly like a normal submitted one.

The expired attempt is recorded as a forfeited zero (`score: 0`, `passed:
false`), so trainer-side results and certificate-eligibility checks see it
the same as any other completed attempt — nothing downstream needs special
handling for "did this time out."

## Rendering guidance

- Start a countdown from `expires_at` the moment `start`/`current-attempt`
  returns it. Auto-call `quiz-attempts` with whatever is answered so far when
  the countdown hits zero, the same way a manual submit works today.
- If the auto-submit request loses the race (slow network, backgrounded
  app) and arrives after the deadline, it will come back as the existing
  "already submitted" rejection rather than a score — show the learner that
  time ran out, not a generic error.
- Re-fetch `current-attempt` on app resume (e.g. after being backgrounded)
  before trusting a client-side timer — if the deadline already passed while
  the app was suspended, the server will report nothing to resume.

## Files touched

- `app/Services/Education/QuizService.php` — `expireIfOverdue()`, called from
  `start()`, `currentAttempt()`, `submit()`
- `app/Http/Controllers/Api/V1/User/LearnerCourseController.php` —
  `expires_at` added to the `start`/`current-attempt` payloads

## Tests

`tests/Feature/Education/QuizTest.php` — within-limit submission still grades
normally, an attempt past its deadline is no longer resumable, a late submit
is rejected, and a new attempt opens after the previous one expires.
