6.0 KiB
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:
export type MatrixCellRank = 1 | 2 | 3 | null;
→
export type MatrixCellRank = 1 | 2 | 3 | 'solved' | null;
1.2 Frontend
frontend/src/app/features/scoreboard/scoreboard.pure.ts:27:
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:
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:
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:
if (payload.position >= 1 && payload.position <= 3) {
cellRow[payload.challengeId] = payload.position as MatrixCellRank;
}
Replace with:
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:
<ng-container [ngSwitch]="view.cells[p.playerId]?.[ch.id]">
<span *ngSwitchCase="1" class="cell-gold" title="1st solver">★</span>
<span *ngSwitchCase="2" class="cell-silver" title="2nd solver">★</span>
<span *ngSwitchCase="3" class="cell-bronze" title="3rd solver">★</span>
<span *ngSwitchCase="'solved'" class="cell-check" title="solved">✓</span>
<span *ngSwitchDefault></span>
</ng-container>
Changes vs today:
- Add an explicit
*ngSwitchCase="'solved'"branch that renders ✓ withclass="cell-check"(the green color already defined atmatrix.component.ts:32). - Remove the
*ngSwitchDefaultbody (which usedcellSolved()and a nested<ng-template #blank>). With the sentinel there is no ambiguity —nullmeans empty,'solved'means checkmark. - The
cellSolved(playerId, challengeId)helper method onMatrixComponentbecomes 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:
expect(matrix.cells['u-d']?.['c1']).toBeNull();
Change to:
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:
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 + removecellSolvedhelper)tests/backend/scoreboard.service.spec.ts(one assertion)tests/frontend/scoreboard.pure.spec.ts(one assertion)
7. Implementation order
- Widen
MatrixCellRankin backend DTO and frontend pure types. - Update backend
getMatrixto store'solved'for positions > 3. - Update
mutateMatrixFromSolveto write'solved'for live 4+ solves. - Update
matrix.component.tstemplate (add'solved'case, drop default helper branch) and deletecellSolved(). - Update the two test assertions.
- Run
npm test(all 590+ tests must pass) andnpm run build.
8. Compatibility & risk
- Public API contract:
cellsJSON gains a new string literal value'solved'. Older clients that already only branch on1|2|3|nullwould render an unknown cell as empty (because ofngSwitchDefaultfalling 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
solvetable. - No impact on Ranking, Event Log, or Score Graph — they do not use
MatrixCellRank.