# Streaks in the AllGifted Math API

> **⚠ Status (2026-05-23)**: Most of this doc still describes current
> behavior, but the *implementation locations* have moved. The
> per-skill mastery streak logic now lives in
> `app/Services/Maxile/MaxileCascade.php` (Step 3.1), not inline in
> `Question::processProgressFor`. The kudos session streak (§3) is
> fully dead code — `KudosService` was deleted. The
> `fail_streak` column was renamed to `wrong_streak` (singular pair with
> `correct_streak`). See [SYSTEM.md](SYSTEM.md) for current code-path
> truth; this doc is the conceptual guide.

This document describes every "streak" concept in this codebase: where it lives,
how it's computed, when it's mutated, and how (or whether) it reaches the
Flutter frontend. There are **three independent streak systems**, and conflating
them has caused bugs in the past — they share a name and nothing else.

| # | Streak | Storage | Computed in | Sent to FE? | FE field |
|---|---|---|---|---|---|
| 1 | Daily-activity streak | Derived from `question_user.answered_date` (not stored) | `HomeController::calculateStreak` | ✅ | `streak` / `stats.streak` |
| 2 | Per-skill mastery streaks | `skill_user.correct_streak`, `skill_user.wrong_streak` | `UserProgressService`, `Question::processProgressFor`, legacy `Skill::handle*` | ❌ (admin Blade only) | — |
| 3 | Kudos session streak | Derived from `question_user` per test (not stored) | `KudosService::getStreakCount` | ❌ **dead code on every live path** — see §3 | — |

---

## 1. Daily-activity streak (user-facing)

The "streak" the Flutter app displays on the dashboard and profile. Consecutive
calendar days on which the user answered at least one question.

### Source of truth

Computed on every request from `question_user`. **No column** stores it on
`users`.

### Algorithm — `HomeController::calculateStreak(int $userId): int`

`app/Http/Controllers/HomeController.php:252-296`

1. Pull all distinct `DATE(answered_date)` values from `question_user` where
   `question_answered = true` and `answered_date IS NOT NULL`, ordered DESC.
2. If no activity → return `0`.
3. If the most recent activity date is **neither today nor yesterday** →
   return `0` (streak is broken).
4. Walk the list. Starting from the most recent date, increment the counter
   for each consecutive day; stop at the first gap.

A user who answered yesterday but not today **still has a streak** (so the
front end can show "answer a question today to keep your streak"). A user
whose last activity was the day before yesterday has a streak of `0`.

### Endpoints that return it

Both under `auth:sanctum`:

| Route | Controller method | Response key |
|---|---|---|
| `GET /api/user/subscription-status` | `HomeController::subscriptionStatus` | `streak` |
| `GET /api/user/profile` | `HomeController::profile` | `stats.streak` |

Defined at `routes/api.php:113-114`.

Example `subscription-status` payload (relevant fields only):

```json
{
  "ok": true,
  "kudos": 1240,
  "lives": 5,
  "overall_maxile": 312,
  "streak": 4,
  "total_questions": 87,
  "topics_practiced": 11
}
```

### Cost

Recomputed every call. The query is:

```sql
SELECT DISTINCT DATE(answered_date)
FROM question_user
WHERE user_id = ?
  AND question_answered = 1
  AND answered_date IS NOT NULL
ORDER BY DATE(answered_date) DESC
```

There is **no index on `answered_date`** today (verified against the live
schema — only `user_id` and the composite uniqueness indexes are present).
The query uses `user_id` to narrow, then table-scans that user's rows for
the DATE grouping. As of 2026-05-21 the table has ~32k rows total / ~27.5k
answered, so for any single user this is fast. If `question_user` grows by
an order of magnitude or this is called in a tight loop, the next step is
a denormalized `users.current_streak` + `users.streak_last_date` updated
on each `/api/answers` call.

---

## 2. Per-skill mastery streaks (`skill_user.correct_streak` / `wrong_streak`)

Internal counters on the `skill_user` pivot. Drive the
**difficulty-progression / mastery** logic: a user passes a difficulty tier
within a skill after N consecutive correct answers; they drop a tier after M
consecutive wrong answers.

These streaks are **not** sent to the Flutter app. They surface only in
admin Blade views (`admin/users/show.blade.php`,
`admin/users/partials/skills-table.blade.php`).

### Schema — `skill_user` pivot

Columns relevant to streaks:

| Column | Type | Purpose |
|---|---|---|
| `correct_streak` | int | Consecutive corrects on this skill at the current `difficulty_passed` tier |
| `wrong_streak` | int | Consecutive incorrects on this skill at the current tier |
| `difficulty_passed` | int | Highest difficulty tier the user has passed for this skill |
| `skill_passed` | int (0/1) | `1` once `difficulty_passed >= Difficulty::tierCount()` |
| `skill_maxile` | decimal | Skill-level maxile, derived from `difficulty_passed` and the track's level range |
| `noOfTries` | int | Total attempts on this skill |
| `total_correct_attempts` | int | Lifetime corrects |
| `total_incorrect_attempts` | int | Lifetime incorrects |

### Migration history

- `2025_05_15_130625_rename_pass_fail_to_streaks_in_skill_user_table.php`
  renamed `noOfPasses` → `correct_streak`, `noOfFails` → `fail_streak`.
  The old names were misleading: they were never lifetime pass/fail
  counters, they were always streaks.
- `2025_05_15_131022_add_total_attempts_to_skill_user.php`
  added `total_correct_attempts` and `total_incorrect_attempts` so the
  lifetime counters live alongside the streaks (rather than being
  derivable by replaying `question_user`).
- `2026_05_23_120000_rename_fail_streak_to_wrong_streak_in_skill_user_table.php`
  renamed `fail_streak` → `wrong_streak` so the paired columns share
  verb form (`correct_streak` / `wrong_streak`). Same migration also
  motivated fixing the latent typo in `SkillUser.php` `$fillable` which
  had `'fail_streaks'` (plural) — now `'wrong_streak'`.

### Update logic (current, primary path)

`app/Models/Question.php` → `processProgressFor()` (lines 285–369).
Called from `AnswerProcessingService::processAnswers`
(`app/Services/AnswerProcessingService.php:112`) after grading each
answer in the batch — wrapped in try/catch so a cascade failure doesn't
roll back the answer save.

Pseudocode:

```text
noOfTries++
if correct:
    correct_streak++; wrong_streak = 0; total_correct++
else:
    wrong_streak++;    correct_streak = 0; total_incorrect++

# Upgrade: passed the current tier
if correct and difficulty > difficulty_passed and correct_streak >= passThreshold:
    difficulty_passed = difficulty
    correct_streak    = 1   # reset (not 0 — counts the streak-completing answer)

# Downgrade: failing at or below the tier you previously passed
elif not correct and difficulty <= difficulty_passed and wrong_streak >= failThreshold:
    difficulty_passed = max(0, difficulty_passed - 1)
    wrong_streak       = 1   # reset

skill_passed = difficulty_passed >= Difficulty::tierCount()

# skill_maxile is interpolated across the track's level range, and
# is monotonic — never drops below previously achieved value.
skill_maxile = max(existing_skill_maxile, computed_value)
```

Thresholds come from `Config::passThreshold()` / `Config::failThreshold()`
(`app/Models/Config.php:113-121`), which read from the `configs` DB table
— **not** from `config/*.php` files. The `configs` table is a single-row
singleton (cached via `self::$cached ??= self::first()`); the relevant
columns are:

| Column | Default | Read via |
|---|---|---|
| `no_rights_to_pass` | `2` | `Config::passThreshold()` |
| `no_wrongs_to_fail` | `2` | `Config::failThreshold()` |

Updated via `syncWithoutDetaching` to preserve other pivot fields.

### Update logic (legacy, still live)

- `app/Services/UserProgressService.php::updateSkillProgress` (line 99) and
  `bulkUpdateSkillProgressOptimized` (line 143) — simpler `+1` / reset, no
  upgrade/downgrade logic. Used by some non-test code paths and bulk
  imports.
- `app/Models/Skill.php::handleAnswer` (line 134), `handleQuiz` (line 92),
  `forcePass` (line 198) — older equivalents of the `Question.php` logic,
  still referenced in some quiz paths. The threshold semantics are the
  same.

The duplication exists because the codebase migrated from per-quiz update
(in `Skill`) to per-answer update (in `Question`) without removing the
old methods. New code should target `Question::processProgressFor`.

---

## 3. Kudos session streak (consecutive corrects within a test) — DEAD CODE

> **Status (verified 2026-05-21)**: this streak is **not active on any
> live endpoint**. `KudosService::getStreakCount` is referenced only by
> `app/Http/Controllers/AnswerController.php` (the root-namespace
> legacy controller), and that controller's only route
> (`/test/answers`) lives inside the commented-out legacy block in
> `routes/api.php:130-189`. Every live grading path computes kudos
> via `AnswerValidationService::checkAnswer` (`(difficulty_id ?? 0) + 1`,
> no streak bonus) — see [[ANSWER_GRADING.md]]. The Phase 1B
> orchestrator explicitly avoids `KudosService`:
> `AnswerGradingService.php:26, 126`.
>
> The original session-streak logic and bonus formula remain
> documented below for reference, since the code is still in the
> repo and could be reactivated by re-routing the legacy controller
> or wiring `KudosService` into one of the live services.

The streak that *would* drive the **kudos bonus multiplier** if active.
Counts consecutive correct answers within a single test (`tests.id`),
not across tests or across days.

Not stored. Computed at the start of each call to the legacy
controller.

### Calculation — `KudosService::getStreakCount($user, $testId)`

`app/Services/KudosService.php:47-72`

1. Pull the user's 10 most recent **answered** `question_user` rows for
   `test_id = $testId`, ordered by `answered_date DESC`.
2. Walk them; count consecutive `correct = true` from the top; break on
   the first incorrect.

The "10 most recent" cap is a soft bound — `correct_base * (streak-1) *
multiplier` saturates fast and there's no need to walk further. If you
need streaks > 10 for any future bonus tier, lift the limit.

### Usage in the legacy `/test/answers` controller

`app/Http/Controllers/AnswerController.php:75, 84-100, 165` — this is
**not** the active `/api/answers` endpoint (which routes to
`API\AnswerController::store` → `AnswerGradingService::grade`). The
legacy controller's batch-answer flow is:

```text
$streakCount = KudosService::getStreakCount($user, $test->id)  # seed from DB

foreach submitted answer:
    $kudos = KudosService::calculateKudos($user, $question, $correct, $time, $streakCount)
    if correct: $streakCount++   # for the *next* iteration only
    else:       $streakCount = 0
```

So the in-memory `$streakCount` is the streak **going into** the current
answer — the bonus uses the count of consecutive corrects that preceded
this answer, not including this one.

### Bonus formula — `KudosService::calculateKudos`

`app/Services/KudosService.php:10-42`

```text
kudos = correct_base + (question.difficulty_id * correct_difficulty_multiplier)

if streak_bonus_enabled and streakCount > 1:
    kudos += kudos * (streakCount - 1) * streak_bonus_multiplier
```

`streakCount > 1` means the bonus kicks in only on the third consecutive
correct (you had a streak of 2 going in, this is correct #3).

### Config

`config/partners.php:17-18`

```php
'streak_bonus_enabled' => false,
'streak_bonus_multiplier' => 0.1,
```

Per-partner override via `LiveService::getConfig($user)['kudos']`. As of
writing the default partner has bonuses **disabled** — turning them on is
a per-partner config change, not a code change.

### What reaches the FE

Only the final kudos number on the `/api/answers` response. There is no
`streak_count` field in the answer-response JSON; the front end can't
display "you're on a streak of 5" without computing it client-side or
adding a field.

---

## Endpoint summary

| HTTP | Route | What it does with streaks |
|---|---|---|
| `GET` | `/api/user/subscription-status` | Returns `streak` (daily activity) |
| `GET` | `/api/user/profile` | Returns `stats.streak` (daily activity) |
| `POST` | `/api/answers` | Reads + extends kudos session streak; mutates per-skill `correct_streak`/`wrong_streak` via `Question::processProgressFor`; does **not** return any streak field |

Admin Blade views additionally read `skill_user.correct_streak` /
`wrong_streak` for the user-detail page; those don't go through any JSON API.

---

## Gotchas

1. **Three different streaks, one name.** When a teammate says "the streak,"
   ask which. Default in user-facing context: daily activity. Default in
   mastery / progression context: per-skill. Default in kudos context:
   kudos session.

2. **Daily-activity streak doesn't persist.** It's a derived view of
   `question_user`. Deleting answer rows changes a user's historical
   streak. If you ever need to freeze the count (badges, leaderboards),
   denormalize it onto `users` first.

3. **Per-skill streak resets to `1`, not `0`, on threshold cross.** Look
   at `Question.php:336` and `Question.php:339`. The streak-completing
   answer "counts" toward the next tier. Don't change this without
   thinking through the pedagogy implications.

4. **`UserProgressService::updateSkillProgress` doesn't apply
   upgrade/downgrade.** It just bumps the streak counters. Code paths
   that go through it bypass mastery progression. Prefer
   `Question::processProgressFor` for any new code that grades an answer.

5. **Kudos session streak doesn't reset across tests.** It's per-test
   only — starting a new test gets a fresh streak. If "streak across the
   whole session" is ever desired, change `getStreakCount` to drop the
   `wherePivot('test_id', ...)` clause.

6. **No `users.streak` column.** A reasonable optimization later, but
   today it's intentionally derived so it can never drift from
   `question_user`. Don't add it without a clear write path on every
   answer submission.

7. **Sanctum guard.** All three controller paths (`HomeController`,
   `AnswerController`) use the Sanctum guard correctly. If you add a new
   endpoint that exposes streak data, follow the pattern in
   `CLAUDE.md` — `$this->user('sanctum')` in FormRequests,
   `Auth::guard('sanctum')->user()` in controllers.
