feat: Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.00

This commit is contained in:
OpenVelo Agent
2026-07-23 05:42:15 +00:00
parent d0c2107efc
commit da5a4451b2
10 changed files with 102 additions and 168 deletions
-162
View File
@@ -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`.
+39
View File
@@ -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 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`.
@@ -373,6 +373,8 @@ export class ChallengesService {
this.publishSolveEvent({
challengeId,
challengeName: challenge.name,
categoryAbbreviation: challenge.abbreviation ?? '',
userId: currentUserId,
username: solvers.find((s) => s.userId === currentUserId)?.username ?? '',
awardedPoints,
@@ -405,6 +407,8 @@ export class ChallengesService {
private publishSolveEvent(input: {
challengeId: string;
challengeName: string;
categoryAbbreviation: string;
userId: string;
username: string;
awardedPoints: number;
@@ -422,6 +426,8 @@ export class ChallengesService {
this.hub.emitScoreboard({
topic: 'solve',
challengeId: input.challengeId,
challengeName: input.challengeName,
categoryAbbreviation: input.categoryAbbreviation,
userId: input.userId,
playerId: input.userId,
playerName: input.username,
@@ -81,6 +81,8 @@ export interface ChallengeDetailDto {
export interface SolveEventPayload {
challengeId: string;
challengeName: string;
categoryAbbreviation: string;
playerId: string;
playerName: string;
awardedPoints: number;
@@ -28,8 +28,9 @@ export class ChallengesEventsController {
* Emits NestJS MessageEvent objects with the proper `type` field so the
* wire format is `event: <type>\ndata: <JSON>\n\n`:
* - event: status, payload = { state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end }
* - event: solve, payload = { challenge_id, player_id, player_name, awarded_points, rank_bonus, awarded_at_utc,
* position, live_points, solve_count, initial_points, minimum_points, decay_solves }
* - event: solve, payload = { challenge_id, challenge_name, category_abbreviation, player_id, player_name,
* awarded_points, rank_bonus, awarded_at_utc, position, live_points, solve_count,
* initial_points, minimum_points, decay_solves }
* Source of truth: every transition of the event window is broadcast by
* the admin general service through SseHubService.event$(). Solves are
* published by ChallengesService through SseHubService.scoreboard$().
@@ -78,6 +79,8 @@ export class ChallengesEventsController {
type: 'solve',
data: {
challenge_id: s?.challengeId,
challenge_name: s?.challengeName ?? '',
category_abbreviation: s?.categoryAbbreviation ?? '',
player_id: s?.playerId ?? s?.userId,
player_name: s?.playerName ?? '',
awarded_points: s?.awardedPoints ?? s?.pointsAwarded,
@@ -60,6 +60,8 @@ export interface GraphView {
export interface SolveLivePayload {
challengeId: string;
challengeName: string;
categoryAbbreviation: string;
playerId: string;
playerName: string;
awardedPoints: number;
@@ -95,6 +95,9 @@ export class ScoreboardStore {
if (!raw) return;
const payload: SolveLivePayload = {
challengeId: raw?.challenge_id ?? raw?.challengeId ?? '',
challengeName: raw?.challenge_name ?? raw?.challengeName ?? '',
categoryAbbreviation:
raw?.category_abbreviation ?? raw?.categoryAbbreviation ?? '',
playerId: raw?.player_id ?? raw?.playerId ?? raw?.userId ?? '',
playerName: raw?.player_name ?? raw?.playerName ?? '',
awardedPoints: Number(raw?.awarded_points ?? raw?.awardedPoints ?? raw?.pointsAwarded ?? 0),
@@ -120,8 +123,8 @@ export class ScoreboardStore {
playerId: payload.playerId,
playerName: payload.playerName,
challengeId: payload.challengeId,
challengeName: '',
categoryAbbreviation: '',
challengeName: payload.challengeName,
categoryAbbreviation: payload.categoryAbbreviation,
position: payload.position,
awardedPoints: payload.awardedPoints,
awardedAtUtc: payload.awardedAtUtc,
@@ -364,6 +364,8 @@ describe('POST /api/v1/challenges/:id/solves', () => {
expect(latest).toMatchObject({
topic: 'solve',
challengeId: c.id,
challengeName: 'sse-emit',
categoryAbbreviation: 'CRY',
playerId: playerId,
position: 1,
awardedPoints: 400 + 15,
+6 -2
View File
@@ -46,6 +46,8 @@ describe('parseSolveEventIntoRanking', () => {
];
const next = parseSolveEventIntoRanking(rows, {
challengeId: 'c1',
challengeName: 'alpha',
categoryAbbreviation: 'CRY',
playerId: 'p2',
playerName: 'p2',
awardedPoints: 50,
@@ -90,7 +92,7 @@ describe('applySolveToGraph', () => {
};
const next = applySolveToGraph(
baseView,
{ challengeId: 'c1', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 2 },
{ challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 2 },
'2025-01-01T00:00:00.000Z',
'2025-01-02T00:00:00.000Z',
'2025-01-01T12:00:00.000Z',
@@ -105,7 +107,7 @@ describe('applySolveToGraph', () => {
it('returns empty when event is not configured', () => {
const next = applySolveToGraph(
null,
{ challengeId: 'c1', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 1 },
{ challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 1 },
null,
null,
'2025-01-01T12:00:00.000Z',
@@ -123,6 +125,8 @@ describe('mutateMatrixFromSolve', () => {
};
const next = mutateMatrixFromSolve(matrix, {
challengeId: 'c1',
challengeName: 'alpha',
categoryAbbreviation: 'CRY',
playerId: 'p2',
playerName: 'p2',
awardedPoints: 50,
+35
View File
@@ -133,6 +133,41 @@ describe('ScoreboardStore', () => {
});
});
it('propagates challenge name and category abbreviation from SSE solve frame to the Event Log row', () => {
const api = new FakeApi();
const destroyRef: any = { onDestroy: () => {} };
const store = new ScoreboardStore(api as any, destroyRef);
void store.loadAll();
return new Promise<void>((resolve) => {
const source = makeFakeSse((type, fn) => {
if (type === 'solve') {
setTimeout(() => {
fn(new MessageEvent('solve', { data: JSON.stringify({
challenge_id: 'c2',
challenge_name: 'Hardware Hello',
category_abbreviation: 'HW',
player_id: 'p3',
player_name: 'PlayerC',
awarded_points: 115,
rank_bonus: 15,
awarded_at_utc: '2026-07-23T05:25:37.309Z',
position: 1,
}) }));
setTimeout(() => {
const top = store.eventLog()?.[0];
expect(top?.challengeName).toBe('Hardware Hello');
expect(top?.categoryAbbreviation).toBe('HW');
expect(top?.playerName).toBe('PlayerC');
expect(top?.awardedPoints).toBe(115);
resolve();
}, 0);
}, 0);
}
});
store.wireSse(() => source);
});
});
it('reset clears all signals and stops SSE', () => {
const api = new FakeApi();
const destroyRef: any = { onDestroy: () => {} };