feat: Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.00
This commit is contained in:
@@ -1,162 +0,0 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Implementation Plan: Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.00
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
- **Codebase style & conventions:** Node.js npm-workspace monorepo with a NestJS 10 backend and Angular 17 standalone frontend, both written in TypeScript. Backend request/business logic is service-oriented; live updates use RxJS-backed NestJS SSE. Frontend feature state uses private Angular signals exposed as read-only signals, with SSE frames normalized in `ScoreboardStore` and then applied immutably. The Event Log template already renders `categoryAbbreviation` and `challengeName`; the defect originates upstream because the solve publish path and authenticated SSE mapper omit those fields, and the frontend currently replaces them with empty strings.
|
||||
- **Data Layer:** TypeORM with `better-sqlite3`. `ChallengesService.submitFlag` already joins `challenge` to `category`, so the successful transaction has `challenge.name` and `challenge.abbreviation` available without an additional query. The reloaded Event Log projection independently joins `solve`, `user`, `challenge`, and `category`, which explains why labels appear after refresh. No schema or migration is required.
|
||||
- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; backend tests are under `tests/backend/` using Nest testing utilities/Supertest and frontend tests are under `tests/frontend/` using jsdom. All tests run from the repository root with `npm test`; focused suites can use `npm run test:backend` and `npm run test:frontend`. Keep regression coverage in the existing dedicated test folders, not beside source files.
|
||||
- **Required Tools & Dependencies:** No new package, system tool, global CLI, persistent `/data` fixture, or `setup.sh` change is required. Existing Node/npm, TypeScript, Jest, NestJS, Angular, RxJS, TypeORM, and SQLite dependencies cover the implementation. Verification should use `npm test` and `npm run build`; the repository exposes no lint script.
|
||||
|
||||
## 2. Impacted Files
|
||||
- **To Modify:**
|
||||
- `backend/src/modules/challenges/dto/challenges.dto.ts` — extend the typed live solve contract with `challengeName` and `categoryAbbreviation`.
|
||||
- `backend/src/modules/challenges/challenges.service.ts` — include challenge/category labels in the post-insert hub payload using metadata already loaded by `submitFlag`.
|
||||
- `backend/src/modules/challenges/events.controller.ts` — forward the two labels in authenticated solve SSE frames, preserving the established snake_case wire format and accepting current camelCase hub fields.
|
||||
- `frontend/src/app/features/scoreboard/scoreboard.pure.ts` — extend `SolveLivePayload` so the normalized live event carries the Event Log labels.
|
||||
- `frontend/src/app/features/scoreboard/scoreboard.store.ts` — parse label fields from SSE aliases and populate the newly prepended `EventLogRow` instead of writing empty strings.
|
||||
- `tests/backend/challenges-submit-flag.spec.ts` — strengthen the existing hub-publish success-path test to assert challenge name and category abbreviation.
|
||||
- `tests/frontend/scoreboard.store.spec.ts` — add focused regression assertions proving an SSE-pushed Event Log row retains both labels.
|
||||
- `docs/api/challenges.md` — update the documented authenticated solve-frame contract and example fields.
|
||||
- `docs/guides/scoreboard-stream.md` — document that live solve frames carry display metadata needed by the Event Log.
|
||||
- **To Create:** None.
|
||||
|
||||
## 3. Proposed Changes
|
||||
1. **Database / Schema Migration:** Make no database changes. Reuse the challenge/category metadata already selected in `ChallengesService.submitFlag`; keep the existing reload query in `ScoreboardService.getEventLog` unchanged.
|
||||
2. **Backend Logic & APIs:**
|
||||
1. Add required `challengeName` and `categoryAbbreviation` properties to `SolveEventPayload` so omissions are caught at the backend contract boundary.
|
||||
2. Extend the private `publishSolveEvent` input and its `SseHubService.emitScoreboard` payload with `challenge.name` and `challenge.abbreviation`, passing them from the successful fresh-solve branch only. Preserve current idempotency: `already_solved`, failed validation, and uniqueness-race fallback paths must not publish a new event.
|
||||
3. Extend `ChallengesEventsController.solveFrame` to emit `challenge_name` and `category_abbreviation` alongside `challenge_id`, player, scoring, and timing data. Do not add a lookup or business logic to the controller; it should remain a pure transport mapping over the enriched hub payload.
|
||||
4. Keep all existing fields and aliases intact so Ranking, Matrix, Graph, Challenges Board, and any current stream consumers remain backward-compatible.
|
||||
3. **Frontend UI Integration:**
|
||||
1. Extend `SolveLivePayload` with `challengeName` and `categoryAbbreviation`.
|
||||
2. In `ScoreboardStore.handleSseFrame`, normalize both snake_case fields (`challenge_name`, `category_abbreviation`) and camelCase fallbacks (`challengeName`, `categoryAbbreviation`) in the same style as existing solve properties.
|
||||
3. In `applySolveEvent`, copy those normalized values into the new `EventLogRow`. Leave `EventLogComponent` unchanged because its `.what` span already renders the abbreviation followed by challenge name, and leave the existing deduplication/cap behavior unchanged.
|
||||
4. Update API/stream documentation so the public contract matches the corrected wire payload and explains that both initial REST rows and live SSE rows contain identical display metadata.
|
||||
|
||||
## 4. Test Strategy
|
||||
- **Target Unit Test File:**
|
||||
- `tests/backend/challenges-submit-flag.spec.ts`: enhance the existing “emits an SSE solve payload” test to assert the emitted hub event contains the seeded challenge name and `CRY` abbreviation. This directly verifies the root publish boundary without adding a large SSE/network fixture.
|
||||
- `tests/frontend/scoreboard.store.spec.ts`: extend the existing live solve-frame test, or add one minimal test, with `challenge_name` and `category_abbreviation`, then assert the prepended Event Log row contains both exact values while retaining awarded points. This covers the user-visible regression through the store’s SSE normalization path without UI/visual testing.
|
||||
- **Mocking Strategy:** Reuse the existing in-memory SQLite Nest application and real `SseHubService` subscription in the backend test; no external service mocks or persistent files are needed. Reuse `FakeApi`, the fake `DestroyRef`, and the lightweight fake `EventSourceLike` in the frontend test; feed a synthetic `MessageEvent` and inspect the public read-only `eventLog` signal. Do not add Playwright, browser, visual, filesystem, network, or `/data` fixtures. Implement test-first: make the focused assertions fail against the current omission, then apply the smallest payload-contract changes and run the root `npm test` plus `npm run build`.
|
||||
Reference in New Issue
Block a user