AI Implementation feature(911): Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.01 (#54)

This commit was merged in pull request #54.
This commit is contained in:
2026-07-23 06:03:13 +00:00
parent 4273662211
commit a39a7daee8
7 changed files with 365 additions and 56 deletions
-39
View File
@@ -1,39 +0,0 @@
# 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 stores 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`.
+202
View File
@@ -0,0 +1,202 @@
# Implementation Plan: Scoreboard — Distinct Line Colors per Top-10 Player on Score Graph
## Status
**Feature NOT yet implemented.** The `PLAYER_COLOR_PALETTE` exposes 10 distinct
hex colors and there are 10 legend entries / 10 polylines, but each `colorIndex`
is derived from `stablePlayerColorIndex(playerId)` — an FNV-1a hash modulo 10.
For many player-id sets (including the reported one) that hash produces
collisions: e.g. `PlayerC` and `Player05` both hash to `5``#ec4899`;
`Player07` and `Player08` both hash to `9``#84cc16`; `PlayerA` and
`Player10` both hash to `2``#10b981`. Verified empirically:
```
hash → palette index
PlayerA → 2 (#10b981) green
PlayerB → 8 (#6366f1)
PlayerC → 5 (#ec4899) pink ← collides with Player05
Player04 → 8 (#6366f1)
Player05 → 5 (#ec4899) pink ← collides with PlayerC
Player06 → 3 (#ef4444)
Player07 → 9 (#84cc16) lime ← collides with Player08
Player08 → 9 (#84cc16) lime ← collides with Player07
Player09 → 3 (#ef4444)
Player10 → 2 (#10b981) green ← collides with PlayerA
```
Collisions are observed across **all four** scoreboard tabs (Ranking swatch,
Matrix first-column swatch, Event Log has no swatch, Score Graph swatch +
polyline) because every component reads its color through the same
`colorIndex → PLAYER_COLOR_PALETTE` lookup.
The Job text limits the symptom to the Score Graph, but the root cause is
shared data (`colorIndex`), so the fix must be applied at the data layer
(`scoreboard.pure.ts`) and re-projected through `scoreboard.store.ts` so the
same top-10 player keeps the same distinct color in every tab.
## 1. Architectural Reconnaissance
- **Framework & language:** TypeScript strict mode, Angular standalone
components, NestJS backend (irrelevant for this fix — the bug is purely
client-side rendering).
- **Code style:** Standalone components with `ChangeDetectionStrategy.OnPush`,
signal-based store, pure-function helpers in `scoreboard.pure.ts` (no
Angular DI), services in `*.service.ts`, store in `*.store.ts`.
- **Data layer:** Pure-function projections over server-supplied payloads.
The four tabs are populated from
`/api/v1/scoreboard/{ranking,matrix,event-log,graph}` (parallel fetch in
`ScoreboardStore.loadAll()`) and incrementally patched in
`ScoreboardStore.applySolveEvent()` from the `/api/v1/events` SSE
`solve` frame.
- **Color model:** A single readonly 10-entry hex array
`PLAYER_COLOR_PALETTE` in `frontend/src/app/features/scoreboard/scoreboard.pure.ts:73`
and a hashing helper `stablePlayerColorIndex(playerId)`
(`scoreboard.pure.ts:86`). Every consumer (ranking, matrix, score-graph)
reads `PLAYER_COLOR_PALETTE[idx]` directly. There is currently **no**
collision-resolution pass.
- **Test framework & structure:** Jest 29 with `ts-jest` preset, configured
by `/repo/tests/jest.config.js` (two projects: `backend` and `frontend`,
the latter with `jsdom`). Tests live exclusively in `/repo/tests/frontend/`
and `/repo/tests/backend/` (never next to source). Single command:
`npm test` from the repo root.
- **Required tools & dependencies:** None new. The fix is purely a
pure-function rewrite inside existing files plus a tiny test addition.
No `setup.sh` change, no new packages.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/scoreboard/scoreboard.pure.ts` — add a
collision-free top-N palette assigner; rewire the three projection
helpers (`parseSolveEventIntoRanking`, `mutateMatrixFromSolve`,
`applySolveToGraph`) so they use it instead of bare
`stablePlayerColorIndex`.
- `frontend/src/app/features/scoreboard/scoreboard.store.ts` — apply
the unique-color projection after `loadAll()` populates each signal
(ranking/matrix/graph) so the initial REST responses also benefit.
- `tests/frontend/scoreboard.pure.spec.ts` — add focused specs covering
the new collision-free assigner (success path: distinct indices for
the documented colliding ID set) and a regression spec asserting
existing single-player behaviour still holds.
- **To Create:** None.
## 3. Proposed Changes
### 3.1. Add a unique-color assigner in `scoreboard.pure.ts`
Export a new pure helper next to `stablePlayerColorIndex`:
```ts
export function assignUniquePlayerColors<T extends { playerId: string }>(
items: readonly T[],
getColorIndex: (t: T) => number,
capacity: number = PLAYER_COLOR_PALETTE.length,
): T[] {
const used = new Set<number>();
const out: T[] = [];
for (const item of items) {
const preferred = ((getColorIndex(item) % capacity) + capacity) % capacity;
let chosen = preferred;
if (used.has(preferred)) {
// Linear probe — the top-10 window guarantees a free slot exists.
let probe = (preferred + 1) % capacity;
while (used.has(probe) && probe !== preferred) {
probe = (probe + 1) % capacity;
}
chosen = probe;
}
used.add(chosen);
out.push({ ...item, colorIndex: chosen } as T);
}
return out;
}
```
Semantics:
- The first player keeps their hashed index; collisions are pushed to the
next free slot via linear probe. With `capacity = 10` and at most 10
players, a free slot is always found (the input length is itself
bounded to ≤ capacity by the upstream `top-10 cutoff` in
`applySolveToGraph`).
- Order is preserved → ranking/rank-cell order is unaffected.
- The mapper is injected so the same helper works for `RankingRow`,
`GraphSeries`, and any future list type that needs a `colorIndex`.
### 3.2. Use the assigner inside the three projection helpers
In `parseSolveEventIntoRanking`, replace the direct assignment at the
"new player" branch (currently
`colorIndex: stablePlayerColorIndex(payload.playerId)`) by:
1. Build a temporary list of the affected/new player with the hashed index.
2. After the sort, call `assignUniquePlayerColors(sorted, (r) => r.colorIndex)`
and return that.
In `mutateMatrixFromSolve`, when a brand-new player is appended, derive
their provisional `colorIndex` from the hash, then post-process the
`players` array with `assignUniquePlayerColors` before returning.
In `applySolveToGraph`, after the new series is added and the array is
sorted + sliced to top 10, run `assignUniquePlayerColors` on the top-10
series so the polyline colors are guaranteed unique. New players appended
after the cutoff don't affect the top-10.
This keeps the FNV hash as the **deterministic preference** (so a
player retains their color across all tabs and across reloads whenever
possible) while guaranteeing uniqueness inside the top-10 window.
### 3.3. Apply the same pass after `loadAll()` in `scoreboard.store.ts`
The initial REST responses already arrive with `colorIndex` set by the
backend (which uses the same hashing convention). After the four
parallel fetches resolve, re-project each view through the new assigner
so the very first paint also shows distinct colors. Specifically, in
the success branch of `loadAll()`:
```ts
this._ranking.set(assignUniquePlayerColors(ranking, (r) => r.colorIndex));
const matrixPlayers = assignUniquePlayerColors(matrix.players, (p) => p.colorIndex);
this._matrix.set({ ...matrix, players: matrixPlayers });
this._graph.set({ ...graph, series: assignUniquePlayerColors(graph.series, (s) => s.colorIndex) });
```
(Backed by tests in §4; the projection is a no-op when there is only
one player or when no collisions exist.)
### 3.4. Verify Ranking/Matrix tabs visually
The existing `ranking.component.ts:68`, `matrix.component.ts:108`, and
`score-graph.component.ts:112` `color(idx)` helpers already guard
against out-of-range indices via the same modulo trick the assigner
uses, so no template changes are required — they keep working as soon as
`colorIndex` is unique.
## 4. Test Strategy
- **Target test file:** `tests/frontend/scoreboard.pure.spec.ts`
(and one regression test for `applySolveToGraph` covering the exact
Job example: ten PlayerC/Player05-style IDs).
- **Mocking strategy:** The helper is a pure function with no DOM, no
Angular DI, and no async — it is imported directly into the existing
spec file, which already mocks only `@angular/common/http` (no extra
setup needed). No `TestBed`, no fixtures, no chart rendering.
- **Spec list (minimal, focused):**
1. `assignUniquePlayerColors` returns distinct indices for the
documented colliding id set
`['PlayerA','PlayerB','PlayerC','Player04','Player05','Player06','Player07','Player08','Player09','Player10']`
and the colors they map to are all different (assert via
`PLAYER_COLOR_PALETTE[idx]` set size === 10).
2. `assignUniquePlayerColors` is a no-op when there are no collisions
(single player, or hashed indices already unique).
3. `applySolveToGraph` end-to-end: feed it an empty view + 10 SSE
solves for 10 colliding players, then assert every series in the
resulting `view.series` has a unique `colorIndex` (regression for
the Job's DOM-inspection symptom at the polyline level).
4. `parseSolveEventIntoRanking` regression: existing test still passes
— proves the rewire did not change ranking sort/points logic.
- **How to run:** `npm test` from the repo root (resolves to the
pre-configured Jest project; frontend project selected automatically
because all impacted specs live under `tests/frontend/`).