AI Implementation feature(910): Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.00 (#53)
This commit was merged in pull request #53.
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`.
|
||||||
@@ -373,6 +373,8 @@ export class ChallengesService {
|
|||||||
|
|
||||||
this.publishSolveEvent({
|
this.publishSolveEvent({
|
||||||
challengeId,
|
challengeId,
|
||||||
|
challengeName: challenge.name,
|
||||||
|
categoryAbbreviation: challenge.abbreviation ?? '',
|
||||||
userId: currentUserId,
|
userId: currentUserId,
|
||||||
username: solvers.find((s) => s.userId === currentUserId)?.username ?? '',
|
username: solvers.find((s) => s.userId === currentUserId)?.username ?? '',
|
||||||
awardedPoints,
|
awardedPoints,
|
||||||
@@ -405,6 +407,8 @@ export class ChallengesService {
|
|||||||
|
|
||||||
private publishSolveEvent(input: {
|
private publishSolveEvent(input: {
|
||||||
challengeId: string;
|
challengeId: string;
|
||||||
|
challengeName: string;
|
||||||
|
categoryAbbreviation: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
awardedPoints: number;
|
awardedPoints: number;
|
||||||
@@ -422,6 +426,8 @@ export class ChallengesService {
|
|||||||
this.hub.emitScoreboard({
|
this.hub.emitScoreboard({
|
||||||
topic: 'solve',
|
topic: 'solve',
|
||||||
challengeId: input.challengeId,
|
challengeId: input.challengeId,
|
||||||
|
challengeName: input.challengeName,
|
||||||
|
categoryAbbreviation: input.categoryAbbreviation,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
playerId: input.userId,
|
playerId: input.userId,
|
||||||
playerName: input.username,
|
playerName: input.username,
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export interface ChallengeDetailDto {
|
|||||||
|
|
||||||
export interface SolveEventPayload {
|
export interface SolveEventPayload {
|
||||||
challengeId: string;
|
challengeId: string;
|
||||||
|
challengeName: string;
|
||||||
|
categoryAbbreviation: string;
|
||||||
playerId: string;
|
playerId: string;
|
||||||
playerName: string;
|
playerName: string;
|
||||||
awardedPoints: number;
|
awardedPoints: number;
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ export class ChallengesEventsController {
|
|||||||
* Emits NestJS MessageEvent objects with the proper `type` field so the
|
* Emits NestJS MessageEvent objects with the proper `type` field so the
|
||||||
* wire format is `event: <type>\ndata: <JSON>\n\n`:
|
* 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: 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,
|
* - event: solve, payload = { challenge_id, challenge_name, category_abbreviation, player_id, player_name,
|
||||||
* position, live_points, solve_count, initial_points, minimum_points, decay_solves }
|
* 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
|
* Source of truth: every transition of the event window is broadcast by
|
||||||
* the admin general service through SseHubService.event$(). Solves are
|
* the admin general service through SseHubService.event$(). Solves are
|
||||||
* published by ChallengesService through SseHubService.scoreboard$().
|
* published by ChallengesService through SseHubService.scoreboard$().
|
||||||
@@ -78,6 +79,8 @@ export class ChallengesEventsController {
|
|||||||
type: 'solve',
|
type: 'solve',
|
||||||
data: {
|
data: {
|
||||||
challenge_id: s?.challengeId,
|
challenge_id: s?.challengeId,
|
||||||
|
challenge_name: s?.challengeName ?? '',
|
||||||
|
category_abbreviation: s?.categoryAbbreviation ?? '',
|
||||||
player_id: s?.playerId ?? s?.userId,
|
player_id: s?.playerId ?? s?.userId,
|
||||||
player_name: s?.playerName ?? '',
|
player_name: s?.playerName ?? '',
|
||||||
awarded_points: s?.awardedPoints ?? s?.pointsAwarded,
|
awarded_points: s?.awardedPoints ?? s?.pointsAwarded,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: api
|
|||||||
title: Challenges Endpoints
|
title: Challenges Endpoints
|
||||||
description: Authenticated challenge board, single-challenge detail, flag submission, event-state snapshot, and the combined authenticated SSE stream that pushes status + solve frames.
|
description: Authenticated challenge board, single-challenge detail, flag submission, event-state snapshot, and the combined authenticated SSE stream that pushes status + solve frames.
|
||||||
tags: [api, challenges, board, score, scoreboard, sse, submit]
|
tags: [api, challenges, board, score, scoreboard, sse, submit]
|
||||||
timestamp: 2026-07-23T05:10:00Z
|
timestamp: 2026-07-23T05:41:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Endpoints
|
# Endpoints
|
||||||
@@ -181,7 +181,7 @@ sources and emits `MessageEvent` frames whose `type` is one of:
|
|||||||
| `event:` | Payload |
|
| `event:` | Payload |
|
||||||
|----------|-------------------------------------------------------------------------------------------------|
|
|----------|-------------------------------------------------------------------------------------------------|
|
||||||
| `status` | `{ state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end }` |
|
| `status` | `{ state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end }` |
|
||||||
| `solve` | `{ challenge_id, player_id, player_name, awarded_points, rank_bonus, awarded_at_utc, position, live_points, solve_count, initial_points, minimum_points, decay_solves }` |
|
| `solve` | `{ 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 }` |
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
|
|
||||||
@@ -203,8 +203,13 @@ appended as-is.
|
|||||||
`backend/src/modules/challenges/dto/challenges.dto.ts`). The
|
`backend/src/modules/challenges/dto/challenges.dto.ts`). The
|
||||||
controller maps both camelCase and snake_case aliases so a
|
controller maps both camelCase and snake_case aliases so a
|
||||||
downstream consumer can read either shape (for example
|
downstream consumer can read either shape (for example
|
||||||
`challenge_id` or `challengeId`, `player_id` or `userId`,
|
`challenge_id` or `challengeId`, `challenge_name` or `challengeName`,
|
||||||
`awarded_points` or `pointsAwarded`).
|
`category_abbreviation` or `categoryAbbreviation`, `player_id` or
|
||||||
|
`userId`, `awarded_points` or `pointsAwarded`). `challenge_name`
|
||||||
|
and `category_abbreviation` carry the same display metadata that
|
||||||
|
the `/api/v1/scoreboard/event-log` projection joins in, so the
|
||||||
|
Scoreboard Event Log tab can render a freshly pushed row without
|
||||||
|
performing a second lookup.
|
||||||
|
|
||||||
# Scoreboard endpoints
|
# Scoreboard endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: guide
|
|||||||
title: Scoreboard Page
|
title: Scoreboard Page
|
||||||
description: How a signed-in player navigates /scoreboard, uses the four tabs (Ranking, Matrix, Event Log, Score Graph), and watches live solve updates arriving over the authenticated SSE stream.
|
description: How a signed-in player navigates /scoreboard, uses the four tabs (Ranking, Matrix, Event Log, Score Graph), and watches live solve updates arriving over the authenticated SSE stream.
|
||||||
tags: [guide, scoreboard, sse, live, ranking, matrix, event-log, score-graph, tester]
|
tags: [guide, scoreboard, sse, live, ranking, matrix, event-log, score-graph, tester]
|
||||||
timestamp: 2026-07-23T05:10:00Z
|
timestamp: 2026-07-23T05:41:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Overview
|
# Overview
|
||||||
@@ -157,7 +157,12 @@ The same `solve` frame that lights up the
|
|||||||
New players are appended to the matrix row list.
|
New players are appended to the matrix row list.
|
||||||
* **Event Log**: a new `EventLogRow` is prepended to the list
|
* **Event Log**: a new `EventLogRow` is prepended to the list
|
||||||
(deduplicated by `solveId` so reconnects don't duplicate) and the
|
(deduplicated by `solveId` so reconnects don't duplicate) and the
|
||||||
list is capped at 200.
|
list is capped at 200. The SSE solve frame carries
|
||||||
|
`challenge_name` / `category_abbreviation` (sourced from the same
|
||||||
|
`challenge` + `category` join that backs the
|
||||||
|
`/api/v1/scoreboard/event-log` REST projection), so the prepended
|
||||||
|
row's category abbreviation and challenge name are populated
|
||||||
|
immediately — no refresh is required.
|
||||||
* **Score Graph**: the matching player's series receives a new
|
* **Score Graph**: the matching player's series receives a new
|
||||||
`{tUtc: awardedAtUtc, value: prevValue + awardedPoints}` point,
|
`{tUtc: awardedAtUtc, value: prevValue + awardedPoints}` point,
|
||||||
the series list is re-sorted by final value DESC and trimmed to the
|
the series list is re-sorted by final value DESC and trimmed to the
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: guide
|
|||||||
title: Scoreboard Stream
|
title: Scoreboard Stream
|
||||||
description: How fresh solves are broadcast to clients — both the public unauthenticated scoreboard SSE stream and the authenticated combined /api/v1/events stream that the /scoreboard page consumes.
|
description: How fresh solves are broadcast to clients — both the public unauthenticated scoreboard SSE stream and the authenticated combined /api/v1/events stream that the /scoreboard page consumes.
|
||||||
tags: [guide, scoreboard, sse, stream, public, authenticated]
|
tags: [guide, scoreboard, sse, stream, public, authenticated]
|
||||||
timestamp: 2026-07-23T05:10:00Z
|
timestamp: 2026-07-23T05:41:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Overview
|
# Overview
|
||||||
@@ -43,9 +43,15 @@ is published to the scoreboard hub.
|
|||||||
* `/api/v1/scoreboard/stream` emits a flattened shape
|
* `/api/v1/scoreboard/stream` emits a flattened shape
|
||||||
(`challengeId`, `userId`, `pointsAwarded`, `rankBonus`,
|
(`challengeId`, `userId`, `pointsAwarded`, `rankBonus`,
|
||||||
`solvedAt`) for anonymous spectators.
|
`solvedAt`) for anonymous spectators.
|
||||||
* `/api/v1/events` emits a richer `SolveEventPayload` (with both
|
* `/api/v1/events` emits a richer `SolveEventPayload` (with both
|
||||||
snake_case and camelCase aliases, plus `position`,
|
snake_case and camelCase aliases, plus `challenge_name`,
|
||||||
`live_points`, `solve_count`, etc.) for the authenticated SPA.
|
`category_abbreviation`, `position`, `live_points`,
|
||||||
|
`solve_count`, etc.) for the authenticated SPA. The display
|
||||||
|
metadata fields are sourced from the same `challenge` +
|
||||||
|
`category` join that backs the `/api/v1/scoreboard/event-log`
|
||||||
|
REST projection, so the Event Log tab can render an SSE-pushed
|
||||||
|
row with the challenge name and category abbreviation without a
|
||||||
|
second lookup.
|
||||||
|
|
||||||
The hub is in-process; multi-replica deployments need a shared
|
The hub is in-process; multi-replica deployments need a shared
|
||||||
pub/sub to fan out across pods (not in scope for this repo).
|
pub/sub to fan out across pods (not in scope for this repo).
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
|
|||||||
controls.
|
controls.
|
||||||
|
|
||||||
The docs below are organized by purpose so agents can pull just the slice
|
The docs below are organized by purpose so agents can pull just the slice
|
||||||
they need. Last regenerated 2026-07-23T05:10:00Z.
|
they need. Last regenerated 2026-07-23T05:41:00Z.
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export interface GraphView {
|
|||||||
|
|
||||||
export interface SolveLivePayload {
|
export interface SolveLivePayload {
|
||||||
challengeId: string;
|
challengeId: string;
|
||||||
|
challengeName: string;
|
||||||
|
categoryAbbreviation: string;
|
||||||
playerId: string;
|
playerId: string;
|
||||||
playerName: string;
|
playerName: string;
|
||||||
awardedPoints: number;
|
awardedPoints: number;
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ export class ScoreboardStore {
|
|||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
const payload: SolveLivePayload = {
|
const payload: SolveLivePayload = {
|
||||||
challengeId: raw?.challenge_id ?? raw?.challengeId ?? '',
|
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 ?? '',
|
playerId: raw?.player_id ?? raw?.playerId ?? raw?.userId ?? '',
|
||||||
playerName: raw?.player_name ?? raw?.playerName ?? '',
|
playerName: raw?.player_name ?? raw?.playerName ?? '',
|
||||||
awardedPoints: Number(raw?.awarded_points ?? raw?.awardedPoints ?? raw?.pointsAwarded ?? 0),
|
awardedPoints: Number(raw?.awarded_points ?? raw?.awardedPoints ?? raw?.pointsAwarded ?? 0),
|
||||||
@@ -120,8 +123,8 @@ export class ScoreboardStore {
|
|||||||
playerId: payload.playerId,
|
playerId: payload.playerId,
|
||||||
playerName: payload.playerName,
|
playerName: payload.playerName,
|
||||||
challengeId: payload.challengeId,
|
challengeId: payload.challengeId,
|
||||||
challengeName: '',
|
challengeName: payload.challengeName,
|
||||||
categoryAbbreviation: '',
|
categoryAbbreviation: payload.categoryAbbreviation,
|
||||||
position: payload.position,
|
position: payload.position,
|
||||||
awardedPoints: payload.awardedPoints,
|
awardedPoints: payload.awardedPoints,
|
||||||
awardedAtUtc: payload.awardedAtUtc,
|
awardedAtUtc: payload.awardedAtUtc,
|
||||||
|
|||||||
@@ -364,6 +364,8 @@ describe('POST /api/v1/challenges/:id/solves', () => {
|
|||||||
expect(latest).toMatchObject({
|
expect(latest).toMatchObject({
|
||||||
topic: 'solve',
|
topic: 'solve',
|
||||||
challengeId: c.id,
|
challengeId: c.id,
|
||||||
|
challengeName: 'sse-emit',
|
||||||
|
categoryAbbreviation: 'CRY',
|
||||||
playerId: playerId,
|
playerId: playerId,
|
||||||
position: 1,
|
position: 1,
|
||||||
awardedPoints: 400 + 15,
|
awardedPoints: 400 + 15,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ describe('parseSolveEventIntoRanking', () => {
|
|||||||
];
|
];
|
||||||
const next = parseSolveEventIntoRanking(rows, {
|
const next = parseSolveEventIntoRanking(rows, {
|
||||||
challengeId: 'c1',
|
challengeId: 'c1',
|
||||||
|
challengeName: 'alpha',
|
||||||
|
categoryAbbreviation: 'CRY',
|
||||||
playerId: 'p2',
|
playerId: 'p2',
|
||||||
playerName: 'p2',
|
playerName: 'p2',
|
||||||
awardedPoints: 50,
|
awardedPoints: 50,
|
||||||
@@ -90,7 +92,7 @@ describe('applySolveToGraph', () => {
|
|||||||
};
|
};
|
||||||
const next = applySolveToGraph(
|
const next = applySolveToGraph(
|
||||||
baseView,
|
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-01T00:00:00.000Z',
|
||||||
'2025-01-02T00:00:00.000Z',
|
'2025-01-02T00:00:00.000Z',
|
||||||
'2025-01-01T12:00:00.000Z',
|
'2025-01-01T12:00:00.000Z',
|
||||||
@@ -105,7 +107,7 @@ describe('applySolveToGraph', () => {
|
|||||||
it('returns empty when event is not configured', () => {
|
it('returns empty when event is not configured', () => {
|
||||||
const next = applySolveToGraph(
|
const next = applySolveToGraph(
|
||||||
null,
|
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,
|
||||||
null,
|
null,
|
||||||
'2025-01-01T12:00:00.000Z',
|
'2025-01-01T12:00:00.000Z',
|
||||||
@@ -123,6 +125,8 @@ describe('mutateMatrixFromSolve', () => {
|
|||||||
};
|
};
|
||||||
const next = mutateMatrixFromSolve(matrix, {
|
const next = mutateMatrixFromSolve(matrix, {
|
||||||
challengeId: 'c1',
|
challengeId: 'c1',
|
||||||
|
challengeName: 'alpha',
|
||||||
|
categoryAbbreviation: 'CRY',
|
||||||
playerId: 'p2',
|
playerId: 'p2',
|
||||||
playerName: 'p2',
|
playerName: 'p2',
|
||||||
awardedPoints: 50,
|
awardedPoints: 50,
|
||||||
|
|||||||
@@ -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', () => {
|
it('reset clears all signals and stops SSE', () => {
|
||||||
const api = new FakeApi();
|
const api = new FakeApi();
|
||||||
const destroyRef: any = { onDestroy: () => {} };
|
const destroyRef: any = { onDestroy: () => {} };
|
||||||
|
|||||||
Reference in New Issue
Block a user