# Plan: Scoreboard Matrix — Distinguish "solved (not top-3)" from "not solved" ## Goal In the Matrix view, a cell must render a green checkmark for *any* solve (positions 1, 2, 3, **and** 4+) and stay empty only when the player genuinely has not solved the challenge. Today the backend stores positions > 3 as `null` (skipped at `backend/src/modules/challenges/scoreboard.service.ts:87`), so the frontend can't tell "solved but not top-3" apart from "never solved". We introduce a `'solved'` sentinel so the frontend can always render ✓ for a real solve. ## 1. Type changes ### 1.1 Backend `backend/src/modules/challenges/dto/scoreboard.dto.ts:21`: ```ts export type MatrixCellRank = 1 | 2 | 3 | null; ``` → ```ts export type MatrixCellRank = 1 | 2 | 3 | 'solved' | null; ``` ### 1.2 Frontend `frontend/src/app/features/scoreboard/scoreboard.pure.ts:27`: ```ts export type MatrixCellRank = 1 | 2 | 3 | null; ``` → identical widening to `1 | 2 | 3 | 'solved' | null`. Both types must stay in lock-step so the controller JSON, the store, and the matrix component all agree on the new sentinel. ## 2. Backend projection `backend/src/modules/challenges/scoreboard.service.ts:83-96` — the per-challenge solver walk currently does: ```ts for (let i = 0; i < list.length; i += 1) { const position = i + 1; if (position > 3) continue; // <- drops positions > 3 ... perPlayer.set(challengeId, position as MatrixCellRank); } ``` Replace the `if (position > 3) continue;` with: ```ts const value: MatrixCellRank = position <= 3 ? (position as 1|2|3) : 'solved'; perPlayer.set(challengeId, value); ``` Everything else in this loop (player-row creation, map writes) stays unchanged. The cells map therefore now contains a row for every solver of every challenge, with `1|2|3|'solved'|null`. ## 3. Frontend live mutation `frontend/src/app/features/scoreboard/scoreboard.pure.ts:246-248` (`mutateMatrixFromSolve`) — currently only writes for positions 1-3: ```ts if (payload.position >= 1 && payload.position <= 3) { cellRow[payload.challengeId] = payload.position as MatrixCellRank; } ``` Replace with: ```ts if (payload.position >= 1 && payload.position <= 3) { cellRow[payload.challengeId] = payload.position as 1 | 2 | 3; } else if (payload.position >= 4) { cellRow[payload.challengeId] = 'solved'; } ``` (no else branch — for `position <= 0` we leave the cell alone, same as today.) With this, a 4th-or-later live solve correctly flips a blank cell to the green ✓. ## 4. Frontend rendering `frontend/src/app/features/scoreboard/matrix.component.ts:86-95` — currently the template uses `*ngSwitchDefault` for non-top-3 cells and re-derives "is this player solved" via a helper method. Now the sentinel tells us directly: ```html ``` Changes vs today: - Add an explicit `*ngSwitchCase="'solved'"` branch that renders ✓ with `class="cell-check"` (the green color already defined at `matrix.component.ts:32`). - Remove the `*ngSwitchDefault` body (which used `cellSolved()` and a nested ``). With the sentinel there is no ambiguity — `null` means empty, `'solved'` means checkmark. - The `cellSolved(playerId, challengeId)` helper method on `MatrixComponent` becomes dead code (its only caller is the template branch we removed) → delete it. ## 5. Test updates ### 5.1 `tests/backend/scoreboard.service.spec.ts` Update the matrix assertion at the "marks top-3 positions" case — currently: ```ts expect(matrix.cells['u-d']?.['c1']).toBeNull(); ``` Change to: ```ts expect(matrix.cells['u-d']?.['c1']).toBe('solved'); ``` (u-d is the 4th solver of c1 in that test, so this validates the new sentinel.) u-b and u-c stay as `2` and `3` respectively. The matrix cell-shape test for the controller (`tests/backend/scoreboard-controller.spec.ts`) does not assert on the >3 sentinel and needs no change. ### 5.2 `tests/frontend/scoreboard.pure.spec.ts` Update `mutateMatrixFromSolve` test: ```ts const next = mutateMatrixFromSolve(matrix, { ... position: 5, }); expect(next!.cells.p2.c1).toBe('solved'); ``` (Was `position: 2` expecting `2`; now uses `position: 5` to prove the new branch fires.) ## 6. Files touched - `backend/src/modules/challenges/dto/scoreboard.dto.ts` (type only) - `backend/src/modules/challenges/scoreboard.service.ts` (one block, ~3 lines) - `frontend/src/app/features/scoreboard/scoreboard.pure.ts` (type + `mutateMatrixFromSolve`) - `frontend/src/app/features/scoreboard/matrix.component.ts` (template + remove `cellSolved` helper) - `tests/backend/scoreboard.service.spec.ts` (one assertion) - `tests/frontend/scoreboard.pure.spec.ts` (one assertion) ## 7. Implementation order 1. Widen `MatrixCellRank` in backend DTO and frontend pure types. 2. Update backend `getMatrix` to store `'solved'` for positions > 3. 3. Update `mutateMatrixFromSolve` to write `'solved'` for live 4+ solves. 4. Update `matrix.component.ts` template (add `'solved'` case, drop default helper branch) and delete `cellSolved()`. 5. Update the two test assertions. 6. Run `npm test` (all 590+ tests must pass) and `npm run build`. ## 8. Compatibility & risk - Public API contract: `cells` JSON gains a new string literal value `'solved'`. Older clients that already only branch on `1|2|3|null` would render an unknown cell as empty (because of `ngSwitchDefault` falling back). To keep current behaviour while migrating, the template change MUST land in the same commit as the backend change (single-tenant SPA, no external consumers — safe). - No DB or migration change; the sentinel is purely a projection change over the existing `solve` table. - No impact on Ranking, Event Log, or Score Graph — they do not use `MatrixCellRank`.