162 lines
6.0 KiB
Markdown
162 lines
6.0 KiB
Markdown
# 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
|
|
<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 ✓
|
|
with `class="cell-check"` (the green color already defined at
|
|
`matrix.component.ts:32`).
|
|
- Remove the `*ngSwitchDefault` body (which used `cellSolved()` and a
|
|
nested `<ng-template #blank>`). 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`. |