AI Implementation feature(865): Scoreboard: Ranking, Matrix, Event Log and Score Graph (#52)

This commit was merged in pull request #52.
This commit is contained in:
2026-07-23 05:13:23 +00:00
parent 49d0930780
commit 7c4843d5cc
26 changed files with 3074 additions and 60 deletions
+170 -2
View File
@@ -2,8 +2,8 @@
type: api
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.
tags: [api, challenges, board, score, sse, submit]
timestamp: 2026-07-23T00:10:00Z
tags: [api, challenges, board, score, scoreboard, sse, submit]
timestamp: 2026-07-23T05:10:00Z
---
# Endpoints
@@ -21,6 +21,10 @@ are not `@Public()`). The DTOs and zod schemas live in
| `GET` | `/api/v1/challenges/:id` | Authenticated | Same. |
| `POST` | `/api/v1/challenges/:id/solves` | Authenticated | Same. |
| `SSE` | `/api/v1/events` | Authenticated (SSE) | `backend/src/modules/challenges/events.controller.ts` |
| `GET` | `/api/v1/scoreboard/ranking` | Authenticated | `backend/src/modules/challenges/scoreboard.controller.ts` |
| `GET` | `/api/v1/scoreboard/matrix` | Authenticated | Same. |
| `GET` | `/api/v1/scoreboard/event-log` | Authenticated | Same. |
| `GET` | `/api/v1/scoreboard/graph` | Authenticated | Same. |
> The combined `/api/v1/events` SSE is **in addition to** the existing
> `/api/v1/events/status` status-only stream. The two share the same
@@ -202,6 +206,169 @@ downstream consumer can read either shape (for example
`challenge_id` or `challengeId`, `player_id` or `userId`,
`awarded_points` or `pointsAwarded`).
# Scoreboard endpoints
The `ScoreboardController` mounts the four authenticated read-only
projections that drive the `/scoreboard` page (Ranking, Matrix,
Event Log, Score Graph tabs). All four live under
`/api/v1/scoreboard/*` and are served by
`backend/src/modules/challenges/scoreboard.controller.ts` via
`ScoreboardService`
(`backend/src/modules/challenges/scoreboard.service.ts`). The DTO
contracts live in
`backend/src/modules/challenges/dto/scoreboard.dto.ts` (zod
`EventLogQuerySchema` plus TypeScript interfaces for the response
shapes).
All four endpoints:
* Require a valid JWT (the global `JwtAuthGuard` is active; no
`@Public()` is used on the scoreboard controller).
* Read exclusively — no mutation — and never expose the `challenge.flag`
field or any admin-only data.
* Compute `awardedPoints` per row using
`computeAwardedPoints(initial, minimum, decaySolves, solveCountBefore)`
from `backend/src/modules/challenges/scoring.util.ts`, which is the
same `basePoints + rankBonus` arithmetic that `submitFlag` performs
when persisting the `solve` row.
* Color each player via `stablePlayerColorIndex(playerId)` (FNV-1a
hash → 10-entry `PLAYER_COLOR_PALETTE`) so the Ranking, Matrix, and
Score Graph share a deterministic swatch per `playerId`.
## `GET /api/v1/scoreboard/ranking`
Returns every user (including zero-solve players) ranked by total
points, with competition-rank numbering (`1, 2, 2, 4, …` — players with
equal `points` share a rank).
Response (`RankingRowDto[]`):
```json
[
{
"rank": 1,
"playerId": "<user-uuid>",
"playerName": "alice",
"solvedCount": 7,
"points": 3120,
"colorIndex": 4
}
]
```
`colorIndex` is the same value the frontend uses to tint the
per-player swatch in the Ranking, Matrix, and Score Graph tabs so a
player keeps their color across all three views.
## `GET /api/v1/scoreboard/matrix`
Returns a players × challenges grid for the live scoreboard view.
Response (`MatrixViewDto`):
```json
{
"players": [ /* RankingRowDto[] in ranking order */ ],
"challenges": [
{
"id": "<challenge-uuid>",
"name": "RSA Rollers",
"solveCount": 4,
"categoryAbbreviation": "CRY"
}
],
"cells": {
"<player-uuid>": {
"<challenge-uuid>": 1,
"<challenge-uuid>": 2,
"<challenge-uuid>": 3,
"<challenge-uuid>": "solved",
"<challenge-uuid>": null
}
}
}
```
`cells[playerId][challengeId]` is one of `1` (gold ★, 1st solver),
`2` (silver ★, 2nd solver), `3` (bronze ★, 3rd solver), `'solved'`
(✓, 4th+ solver), or `null` (not solved). Challenges are sorted by
`solveCount` DESC, then by `name` ASC. The service walks the `solve`
rows in `solved_at ASC` order per challenge to assign each cell its
first solver rank.
## `GET /api/v1/scoreboard/event-log`
Recent solves, newest first, joined with the user, challenge, and
category so the frontend never needs a second round-trip to render
labels.
Query string (`EventLogQuerySchema`, validated by `ZodValidationPipe`):
| Param | Type | Default | Description |
|---------|------|---------|----------------------------------------------|
| `limit` | int | `50` | `1 <= limit <= 200`, clamped server-side too. |
Response (`EventLogRowDto[]`):
```json
[
{
"solveId": "<solve-uuid>",
"playerId": "<user-uuid>",
"playerName": "bob",
"challengeId": "<challenge-uuid>",
"challengeName": "Forensic Trail",
"categoryAbbreviation": "FOR",
"position": 1,
"awardedPoints": 515,
"awardedAtUtc": "2026-07-23T05:01:30.000Z"
}
]
```
## `GET /api/v1/scoreboard/graph`
Time-series of cumulative points for the top 10 players over the
configured event window, used to drive the Score Graph tab.
Response (`GraphViewDto`):
```json
{
"startUtc": "2026-07-23T05:00:00.000Z",
"endUtc": "2026-07-23T09:00:00.000Z",
"serverNowUtc": "2026-07-23T05:10:00.000Z",
"state": "running",
"series": [
{
"playerId": "<user-uuid>",
"playerName": "alice",
"colorIndex": 4,
"points": [
{ "tUtc": "2026-07-23T05:00:00.000Z", "value": 0 },
{ "tUtc": "2026-07-23T05:01:30.000Z", "value": 515 },
{ "tUtc": "2026-07-23T05:09:50.000Z", "value": 980 },
{ "tUtc": "2026-07-23T09:00:00.000Z", "value": 980 }
]
}
]
}
```
`state` mirrors the 4-state event window (`running` / `countdown` /
`stopped` / `unconfigured`) and `startUtc` / `endUtc` come from
`SettingsService` via the admin general-settings pipeline. When the
event has not yet started (`countdown`) or has no window configured
(`unconfigured`), the endpoint still returns `200` with empty
`series` and `null` boundaries so the frontend can render the
"Awaiting event configuration" / "Not started" empty states.
Series are sorted by their final cumulative value DESC and trimmed to
the top 10. Each series starts with a `{tUtc: startUtc, value: 0}`
anchor and (when `endUtc` is known) ends with a
`{tUtc: endUtc, value: finalValue}` step so the line plot shows a
flat plateau between the last solve and the event end.
# Errors
The challenges endpoints use three additional error codes from
@@ -221,5 +388,6 @@ The full error envelope and middleware are documented in
- [Challenge Tables](/database/challenges.md)
- [System Endpoints](/api/system.md) (legacy `/events/status` and `/event/stream`)
- [Scoreboard Stream](/guides/scoreboard-stream.md)
- [Scoreboard Page Guide](/guides/scoreboard-page.md)
- [Challenges Board Guide](/guides/challenges-board.md)
- [Backend Module Map](/architecture/backend-modules.md)
+10 -3
View File
@@ -2,8 +2,8 @@
type: architecture
title: Frontend Structure
description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport.
tags: [architecture, frontend, angular, shell, sse, challenges, notifications]
timestamp: 2026-07-23T00:10:00Z
tags: [architecture, frontend, angular, shell, sse, challenges, scoreboard, notifications]
timestamp: 2026-07-23T05:10:00Z
---
# Routes
@@ -16,7 +16,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
| `/login` | `LandingComponent` | `landingGuard` | Public landing and authentication modal. |
| `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. |
| `/challenges` | `ChallengesPage` | inherited | Full challenges board (category columns, modal, flag submit, live SSE). |
| `/scoreboard` | `ScoreboardPage` | inherited | Placeholder scoreboard page. |
| `/scoreboard` | `ScoreboardPage` | inherited | Live scoreboard page with 4 tabs (Ranking / Matrix / Event Log / Score Graph), live SSE `solve` updates. |
| `/blog` | `BlogPage` | inherited | Placeholder blog page. |
| `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. |
| `/admin/challenges` | `AdminChallengesComponent` | inherited | Sortable/searchable admin Challenges list, Add/Edit modal, import/export. |
@@ -37,6 +37,12 @@ Routes live in `frontend/src/app/app.routes.ts`:
| `ChallengesApiService` | `frontend/src/app/features/challenges/challenges.service.ts` | HTTP wrapper around `/api/v1/challenges/{board,status,:id,:id/solves}` returning `Observable<...>` and translating `HttpErrorResponse` into a typed `ApiErrorEnvelope`. |
| `challenges.pure.ts` | `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types + helpers for the challenges feature (`BoardCard`, `SolverRow`, `SolveEventPayload`, sorting, parsers, friendly error mapping, `formatDdHhMm`). |
| `ChallengeCardComponent` / `CategoryColumnComponent` / `ChallengeModalComponent` | `frontend/src/app/features/challenges/` | Presentational components for the board grid, column, and detail modal. |
| `ScoreboardPage` | `frontend/src/app/features/scoreboard/scoreboard.page.ts` | Smart page for `/scoreboard`: holds a `ScoreboardStore`, calls `store.loadAll()` + `store.wireSse(...)` on init, tears them down on destroy, and switches between the 4 tab components via `*ngIf` on `store.activeTab()`. |
| `ScoreboardStore` | `frontend/src/app/features/scoreboard/scoreboard.store.ts` | Root-provided signal store backing `/scoreboard`: holds the four projections, active tab, loading + error state, and the SSE lifecycle (`wireSse` / `stop` / exponential reconnect on transport error). Mutates all four tabs from each `solve` frame via pure helpers (`parseSolveEventIntoRanking`, `mutateMatrixFromSolve`, `applySolveToGraph`, `dedupEventLogBySolveId`). |
| `ScoreboardApiService` | `frontend/src/app/features/scoreboard/scoreboard.service.ts` | HTTP wrapper around `/api/v1/scoreboard/{ranking,matrix,event-log,graph}` returning typed `ApiErrorEnvelope`s. |
| `scoreboard.pure.ts` | `frontend/src/app/features/scoreboard/scoreboard.pure.ts` | Pure types + helpers shared between the store and the four tab components (`RankingRow`, `MatrixView`, `EventLogRow`, `GraphView`, `SolveLivePayload`, `PLAYER_COLOR_PALETTE`, `stablePlayerColorIndex`, `applyRankingSort`, `parseSolveEventIntoRanking`, `dedupEventLogBySolveId`, `applySolveToGraph`, `mutateMatrixFromSolve`, `formatSolveDateTime`). |
| `ScoreboardTabsComponent` | `frontend/src/app/features/scoreboard/tabs.component.ts` | Horizontal tab strip bound to `store.activeTab()`; emits `(select)` to switch tabs. |
| `RankingComponent` / `MatrixComponent` / `EventLogComponent` / `ScoreGraphComponent` | `frontend/src/app/features/scoreboard/` | Presentational components for the 4 tabs; each binds a single `@Input()` from `ScoreboardStore` and uses `PLAYER_COLOR_PALETTE` + `stablePlayerColorIndex` to color each player consistently. |
| `NotificationService` | `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. |
| `errorNotificationInterceptor` | `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Functional HTTP interceptor that pushes a friendly message into `NotificationService` for every `HttpErrorResponse`; suppresses duplicate toasts for endpoints with their own error UI (login form, `/challenges/status` snapshot). |
@@ -121,5 +127,6 @@ for the full contract and tester matrix.
- [Authenticated Shell](/guides/authenticated-shell.md)
- [Event Window](/guides/event-window.md)
- [Challenges Board](/guides/challenges-board.md)
- [Scoreboard Page](/guides/scoreboard-page.md)
- [Notifications](/guides/notifications.md)
- [System Overview](/architecture/overview.md)
+19 -1
View File
@@ -35,8 +35,11 @@ timestamp: 2026-07-23T04:05:00Z
| `backend/src/modules/challenges/challenges.controller.ts` | Registers `/api/v1/challenges/{board,status,:id,:id/solves}` (all JWT-protected). |
| `backend/src/modules/challenges/events.controller.ts` | Authenticated SSE `/api/v1/events` merging status ticks + hub `general` pushes + `scoreboard$` solve frames. |
| `backend/src/modules/challenges/challenges.service.ts` | `getBoard` / `getDetail` / `submitFlag` + `dataSource.transaction` for race-safe idempotent solves; publishes `solve` SSE frames on success. |
| `backend/src/modules/challenges/scoreboard.controller.ts` | Registers the four authenticated scoreboard read-only endpoints under `/api/v1/scoreboard/{ranking,matrix,event-log,graph}`. |
| `backend/src/modules/challenges/scoreboard.service.ts` | Builds the `RankingRowDto[]`, `MatrixViewDto`, `EventLogRowDto[]`, and `GraphViewDto` projections used by the `/scoreboard` page; consumes `SettingsService` for the event window and `EventStatusService` for `state`. |
| `backend/src/modules/challenges/dto/scoreboard.dto.ts` | `EventLogQuerySchema` (zod) + TypeScript DTO interfaces (`RankingRowDto`, `MatrixViewDto`, `EventLogRowDto`, `GraphViewDto`, `GraphSeriesDto`, `GraphPointDto`) for the four scoreboard endpoints. |
| `backend/src/modules/challenges/dto/challenges.dto.ts` | zod contracts (`SolveSubmitBodySchema`, `BoardQuerySchema`, `ChallengeIdParamSchema`) + DTO types for the player-facing board. |
| `backend/src/modules/challenges/scoring.util.ts` | Pure `computeLivePoints`, `rankBonusForPosition`, `compareDifficulty`, and constant-time `safeEqualString` for flag comparison. |
| `backend/src/modules/challenges/scoring.util.ts` | Pure `computeLivePoints`, `rankBonusForPosition`, `compareDifficulty`, `computeAwardedPoints` (base + rank bonus), `PLAYER_COLOR_PALETTE` + `stablePlayerColorIndex` (FNV-1a → palette index), and constant-time `safeEqualString` for flag comparison. |
| `backend/src/common/errors/error-codes.ts` | Canonical error code map (now includes `EVENT_NOT_RUNNING`, `FLAG_INCORRECT`, `CHALLENGE_DISABLED`). |
# Frontend
@@ -79,6 +82,16 @@ timestamp: 2026-07-23T04:05:00Z
| `frontend/src/app/features/challenges/category-column.component.ts` | Renders a single category column (header + cards) on the challenges board. |
| `frontend/src/app/features/challenges/challenge-card.component.ts` | Renders a single challenge card on the board as a semantic `<button>` (difficulty, live points, solve count, `✓` when solved by the player). Exposes `data-testid="challenge-card-<id>"`, `data-solved`, `aria-pressed`, an accessible name (`Challenge <name>, solved|not solved`), and an inner `data-testid="challenge-check-<id>"` for the solved glyph. |
| `frontend/src/app/features/challenges/challenge-modal.component.ts` | Challenge detail modal: description, flag form, awarded banner, solvers list, live-solve updates. |
| `frontend/src/app/features/scoreboard/scoreboard.page.ts` | `/scoreboard` smart page: 4 tabs (Ranking / Matrix / Event Log / Score Graph) wired through `ScoreboardStore`, SSE `solve`-frame subscription on `/api/v1/events`, and lifecycle (`loadAll` + `wireSse` on init, `stop()` on destroy). |
| `frontend/src/app/features/scoreboard/scoreboard.store.ts` | Signal store for the four scoreboard projections + active tab + SSE lifecycle; mutates `ranking`, `matrix`, `event-log`, and `graph` state from each `solve` frame via pure helpers (`parseSolveEventIntoRanking`, `mutateMatrixFromSolve`, `applySolveToGraph`, `dedupEventLogBySolveId`); exposes `wireSse`/`stop`/`reset` and a 1s→30s exponential reconnect loop on transport error. |
| `frontend/src/app/features/scoreboard/scoreboard.service.ts` | HTTP client for `/api/v1/scoreboard/{ranking,matrix,event-log,graph}` returning typed `ApiErrorEnvelope`s. |
| `frontend/src/app/features/scoreboard/scoreboard.pure.ts` | Pure types and helpers: `RankingRow`, `MatrixView`, `EventLogRow`, `GraphView`, `SolveLivePayload`, `PLAYER_COLOR_PALETTE`, `stablePlayerColorIndex`, `applyRankingSort`, `parseSolveEventIntoRanking`, `dedupEventLogBySolveId`, `applySolveToGraph`, `mutateMatrixFromSolve`, `formatSolveDateTime`. |
| `frontend/src/app/features/scoreboard/tabs.component.ts` | Horizontal tab strip (`Ranking` / `Matrix` / `Event Log` / `Score Graph`) bound to `ScoreboardStore.activeTab`. |
| `frontend/src/app/features/scoreboard/ranking.component.ts` | Ranking tab table (rank, player with color swatch, solved count, points). |
| `frontend/src/app/features/scoreboard/matrix.component.ts` | Matrix tab — sticky-header, sticky-player grid of cells (`★` gold/silver/bronze, `✓`, blank) with player color swatches. |
| `frontend/src/app/features/scoreboard/event-log.component.ts` | Event Log tab — newest-first `<ol>` of solves with timestamp, player, challenge, awarded points; position 13 use gold/silver/bronze stars, others use a green check. |
| `frontend/src/app/features/scoreboard/score-graph.component.ts` | Score Graph tab — pure-SVG line chart of top 10 cumulative points over the event window with per-player colored polylines and a legend; renders empty / countdown / unconfigured states. |
| `frontend/src/app/features/scoreboard/scoreboard.page.ts` | `/scoreboard` smart page (also listed above): gates the four tabs, wires `store.loadAll()` and `store.wireSse(...)` on init, and tears them down via `store.stop()` on destroy. |
| `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. |
| `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Translates `HttpErrorResponse` into friendly messages and pushes them to `NotificationService` (with suppression for `/api/v1/auth/{login,csrf,register}` and `/api/v1/challenges/status`). |
| `tests/frontend/challenges.pure.spec.ts` | Pure helpers: sorting, parsers, friendly error mapping, `formatDdHhMm` (`DD:HH:mm:ss`). |
@@ -91,6 +104,10 @@ timestamp: 2026-07-23T04:05:00Z
| `tests/backend/challenges-status-rest.spec.ts` | `/api/v1/challenges/status` snapshot shape. |
| `tests/backend/challenges-events-sse.spec.ts` | `/api/v1/events` SSE: status + solve frames, dedup, ordering. |
| `tests/backend/challenges-submit-flag.spec.ts` | Submit flow: correct/incorrect flag, idempotent re-submit, event-state guard, race handling. |
| `tests/backend/scoreboard-controller.spec.ts` | Contract tests for the four `/api/v1/scoreboard/*` endpoints: JWT protection, ranking sort + competition rank, matrix cell assignment (1/2/3/solved/null), event-log `limit` clamp, graph boundaries + top-10 trim + empty state. |
| `tests/backend/scoreboard.service.spec.ts` | Service-level tests covering `computeAwardedPoints` parity with `submitFlag`, color-index stability, matrix ordering, and graph state handling. |
| `tests/frontend/scoreboard.pure.spec.ts` | Pure helpers: `applyRankingSort` (competition-rank numbering), `parseSolveEventIntoRanking` (add-or-increment + sort), `dedupEventLogBySolveId`, `applySolveToGraph` (anchors + plateau + top-10 trim), `mutateMatrixFromSolve`, `stablePlayerColorIndex`, `formatSolveDateTime`. |
| `tests/frontend/scoreboard.store.spec.ts` | Store lifecycle + live merge: `loadAll` aggregation + idempotency, SSE `solve` frame applying to all four tabs, `stop()` teardown, exponential reconnect. |
# See also
@@ -98,5 +115,6 @@ timestamp: 2026-07-23T04:05:00Z
- [Authenticated Shell](/guides/authenticated-shell.md)
- [System Endpoints](/api/system.md)
- [Challenges Endpoints](/api/challenges.md)
- [Scoreboard Page Guide](/guides/scoreboard-page.md)
- [Challenges Board](/guides/challenges-board.md)
- [Notifications](/guides/notifications.md)
+262
View File
@@ -0,0 +1,262 @@
---
type: guide
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.
tags: [guide, scoreboard, sse, live, ranking, matrix, event-log, score-graph, tester]
timestamp: 2026-07-23T05:10:00Z
---
# Overview
The `/scoreboard` page (the `ScoreboardPage` smart component in
`frontend/src/app/features/scoreboard/scoreboard.page.ts`) renders the
four live scoreboard projections backed by
`/api/v1/scoreboard/{ranking,matrix,event-log,graph}` and updates
them in real time as new solves come in. It is reachable from the
authenticated shell quick-tab strip (the **Scoreboard** tab next to
Challenges and Blog).
# Navigation
1. Sign in as any user at `/login`.
2. Click the **Scoreboard** quick tab in the authenticated shell
header (or navigate directly to `/scoreboard`).
3. The page mounts, shows a short `Loading…` indicator while it
fetches all four projections in parallel, then renders the active
tab.
You must be authenticated; `/scoreboard` is a child of the
authenticated `HomeComponent` shell, so unauthenticated visitors are
redirected to `/login` by `authGuard`.
# The four tabs
The tab strip (`data-testid="scoreboard-tabs"`) sits directly under
the `Scoreboard` page heading. There are four mutually-exclusive
tabs:
| Tab | Test ID of the active tab button | Backing component | Underlying endpoint |
|---|---|---|---|
| Ranking | `scoreboard-tab-ranking` | `RankingComponent` | `GET /api/v1/scoreboard/ranking` |
| Matrix | `scoreboard-tab-matrix` | `MatrixComponent` | `GET /api/v1/scoreboard/matrix` |
| Event Log | `scoreboard-tab-event-log` | `EventLogComponent` | `GET /api/v1/scoreboard/event-log?limit=50` |
| Score Graph | `scoreboard-tab-graph` | `ScoreGraphComponent` | `GET /api/v1/scoreboard/graph` |
Click a tab to switch the visible body. The selected tab is styled
with the primary theme color (`.active`) and the previous tab's
component is destroyed by Angular's `*ngIf`.
The active tab is stored in `ScoreboardStore.activeTab`; it defaults
to `ranking` on first load and is preserved across tab switches but
reset to `ranking` when `ScoreboardStore.reset()` runs (e.g. on
session invalidation).
## Ranking tab
A table (`data-testid="scoreboard-ranking"`) with columns **Rank**,
**Player**, **Solved**, **Points**:
* Each row has `data-testid="ranking-row-<playerId>"` and a
`data-rank="<n>"` attribute.
* Each player name is prefixed with a colored swatch (10-entry
`PLAYER_COLOR_PALETTE` chosen by `stablePlayerColorIndex`) so the
same player keeps the same color across all four tabs.
* Ranking uses competition ranks: equal-point players share the same
`rank` (1, 2, 2, 4, …). Zero-solve players are still listed at the
bottom in user-id order.
* Empty state: when no users exist yet, the table is replaced with
the centered text `No players yet` (`data-testid="ranking-empty"`).
## Matrix tab
A players × challenges grid (`data-testid="scoreboard-matrix"`)
wrapped in a scrollable container
(`data-testid="scoreboard-matrix-scroll"`):
* The first column is **Player** (sticky horizontally) with the same
colored swatch as the Ranking tab.
* Each subsequent column is a challenge, prefixed by its
`categoryAbbreviation` (e.g. `CRY RSA Rollers`). Columns are sorted
by `solveCount` DESC then by challenge name ASC.
* Cells encode the position at which that player solved that
challenge:
| Cell content | Meaning |
|---|---|
| ★ (gold) | 1st solver (`data-testid="matrix-cell-<playerId>-<challengeId>"` with `title="1st solver"`) |
| ★ (silver) | 2nd solver |
| ★ (bronze) | 3rd solver |
| ✓ (green check) | 4th+ solver (`title="solved"`) |
| blank | Not solved by this player |
* Row and column headers have `data-testid="matrix-row-<playerId>"` and
`data-testid="matrix-col-<challengeId>"` respectively so tests can
target them directly.
* Empty state: when no players exist yet, the grid is replaced with
the centered text `No players yet` (`data-testid="matrix-empty"`).
## Event Log tab
A vertically scrolling newest-first `<ol>`
(`data-testid="scoreboard-event-log"`) capped at 200 entries:
* Each `<li>` has `data-testid="event-log-row-<solveId>"`.
* Each row shows, left-to-right:
* An icon — gold/silver/bronze `★` for positions 1/2/3, green `✓`
otherwise.
* The local timestamp formatted `YYYY-MM-DD HH:mm:ss` via
`formatSolveDateTime`.
* The player name.
* The category abbreviation (e.g. `FOR`) followed by the challenge
name.
* `+<awardedPoints>` right-aligned.
* Empty state: when no solves have been recorded, the list is
replaced with the centered text `No solves yet`
(`data-testid="event-log-empty"`).
## Score Graph tab
A pure-SVG line chart (`data-testid="score-graph-svg"`) of cumulative
points for the **top 10 players** over the configured event window
(`startUtc``endUtc`):
* Above the chart, a legend (`data-testid="score-graph-legend-<playerId>"`)
lists each player with their colored swatch.
* Each player has a `polyline` element
(`data-testid="score-graph-line-<playerId>"`) using the same color
index as the Ranking and Matrix tabs.
* The X axis is the event window time (`startUtc``endUtc`) and the
Y axis is cumulative points. Each line starts at `(startUtc, 0)`,
steps up at every solve, and (when `endUtc` is known) plateaus at
the final value until `endUtc`.
* Three empty states, switched by `view.state`:
| `state` | Visible text | Test ID |
|---|---|---|
| `unconfigured` | `Awaiting event configuration` | `score-graph-empty` |
| `countdown` | `Not started` | `score-graph-not-started` |
| `running` / `stopped` with empty series | `No solves yet` | `score-graph-empty` |
# Live updates
On `ngOnInit`, `ScoreboardPage` calls `store.loadAll()` (which fans
out to all four `GET /api/v1/scoreboard/*` endpoints in parallel) and
then `store.wireSse(() => authenticatedEventSource.open('/api/v1/events'))`
to subscribe to the existing authenticated combined SSE stream.
The same `solve` frame that lights up the
[Challenges Board](/guides/challenges-board.md) is also consumed by
`ScoreboardStore` and applied to **all four** tabs:
* **Ranking**: the matching player's `points` increases by
`awardedPoints`, `solvedCount` increments by 1, and the list is
re-sorted (with competition ranks applied). If the player is new,
they are appended with `points = awardedPoints`, `solvedCount = 1`.
* **Matrix**: the cell at `[<playerId>, <challengeId>]` becomes `1`,
`2`, or `3` if the new `position` is in 13, otherwise `'solved'`.
New players are appended to the matrix row list.
* **Event Log**: a new `EventLogRow` is prepended to the list
(deduplicated by `solveId` so reconnects don't duplicate) and the
list is capped at 200.
* **Score Graph**: the matching player's series receives a new
`{tUtc: awardedAtUtc, value: prevValue + awardedPoints}` point,
the series list is re-sorted by final value DESC and trimmed to the
top 10.
On `ngOnDestroy`, `store.stop()` aborts the SSE transport and clears
the reconnect timer so no callbacks fire after the page leaves the
DOM.
# Errors and edge cases
* **Unauthenticated visit**: the `authGuard` on the parent shell
redirects to `/login`; the scoreboard page itself does not handle
this case.
* **Backend unreachable on initial load**: `store.error()` is set to
the failure message and rendered in the page above the active tab
(`data-testid="scoreboard-error"`) with the `color-danger` color.
The active tab is replaced with nothing visible (the tab
components themselves stay in their empty state until the next
successful `loadAll`).
* **SSE drops**: `ScoreboardStore` schedules a reconnect with an
exponential backoff (1s → 2s → 4s → … capped at 30s). When the
reconnect succeeds the next `solve` frame re-applies to whatever
state the page currently has.
* **`401` / `403` SSE response**: dispatched as a separate
`'unauthorized'` event by `AuthenticatedEventSourceService`;
`ScoreboardStore` accepts an optional `onUnauthorized` callback
for the page to plug into the cross-tab invalidation flow (see
[Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md)).
* **Session change (logout in another tab)**: `HomeComponent`
invalidation flow calls `ChallengesStore.reset()` but not the
scoreboard store — the scoreboard store is reset automatically by
Angular when the `ScoreboardPage` is destroyed as part of the
navigation to `/login`.
# Visual elements
| Element | Test ID | Notes |
|---|---|---|
| Page root | `scoreboard-page` | Wrapper section. |
| Tab strip | `scoreboard-tabs` | Horizontal `<nav>` with 4 buttons. |
| Per-tab button | `scoreboard-tab-<tabId>` | One of `ranking` / `matrix` / `event-log` / `graph`. |
| Loading indicator | `scoreboard-loading` | Shown while `store.loading()` is true. |
| Error banner | `scoreboard-error` | Shown when `store.error()` is non-null. |
| Ranking table | `scoreboard-ranking` | See "Ranking tab" above. |
| Ranking row | `ranking-row-<playerId>` | Includes `data-rank`. |
| Ranking empty | `ranking-empty` | "No players yet" copy. |
| Matrix scroll | `scoreboard-matrix-scroll` | Scroll wrapper. |
| Matrix table | `scoreboard-matrix` | Sticky-header grid. |
| Matrix row | `matrix-row-<playerId>` | Sticky-left first column. |
| Matrix column header | `matrix-col-<challengeId>` | Sticky-top. |
| Matrix cell | `matrix-cell-<playerId>-<challengeId>` | ★/✓/blank. |
| Matrix empty | `matrix-empty` | "No players yet" copy. |
| Event log list | `scoreboard-event-log` | Newest-first `<ol>`. |
| Event log row | `event-log-row-<solveId>` | One per solve. |
| Event log empty | `event-log-empty` | "No solves yet" copy. |
| Score graph frame | `score-graph` | Wrapper with legend + SVG. |
| Score graph SVG | `score-graph-svg` | The line chart. |
| Score graph line | `score-graph-line-<playerId>` | Per-player `<polyline>`. |
| Score graph legend item | `score-graph-legend-<playerId>` | Per-player label. |
| Score graph not-started | `score-graph-not-started` | "Not started" (countdown state). |
| Score graph empty | `score-graph-empty` | "No solves yet" or "Awaiting event configuration". |
# Examples
## Initial happy path
1. Sign in as a user who has solved 3 challenges across two
categories.
2. Open `/scoreboard`. You see a brief `Loading…` then the **Ranking**
tab active by default.
3. Click **Matrix**. The grid shows your row with your colored swatch
in the first column and three cells in the matching challenge
columns (gold/silver/bronze stars if you were 1st/2nd/3rd,
otherwise green checks).
4. Click **Event Log**. Your three solves appear newest-first with
gold/silver/bronze or check icons, the formatted timestamp, the
challenge name, and the `+<points>` totals.
5. Click **Score Graph**. Your colored polyline climbs from 0 at the
event start to your final cumulative points.
## Watching a live solve
1. With the scoreboard open, in another browser profile (or a second
device) sign in as a different user and submit a correct flag.
2. Within ~1 second the `/api/v1/events` SSE frame arrives and the
current tab updates without a reload: a new ranking row (or an
update to an existing one), a new matrix cell, a new event-log row
prepended at the top, and a new step on the score-graph polyline.
3. The colored swatch for the new player is identical across all four
tabs and matches the same player's swatch in the challenges board
modal solvers list.
# See also
- [Challenges Endpoints](/api/challenges.md) — `/api/v1/scoreboard/*` request/response shapes.
- [Scoreboard Stream](/guides/scoreboard-stream.md) — backend push path for the `solve` SSE frames this page consumes.
- [Challenges Board](/guides/challenges-board.md) — the upstream page whose `submitFlag` produces the `solve` frames.
- [Authenticated Shell](/guides/authenticated-shell.md) — the parent shell, header, and quick-tab strip.
- [Event Window](/guides/event-window.md) — the `startUtc`/`endUtc` boundaries used by the Score Graph tab.
- [Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md) — the `onUnauthorized` SSE callback this page subscribes to.
- [Key Files Index](/architecture/key-files.md) — the canonical file responsibilities for the scoreboard feature.
+69 -20
View File
@@ -1,37 +1,86 @@
---
type: guide
title: Scoreboard Stream
description: How live solves are broadcast to the SPA over Server-Sent Events.
tags: [guide, scoreboard, sse, stream]
timestamp: 2026-07-21T18:28:00Z
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]
timestamp: 2026-07-23T05:10:00Z
---
# Endpoint
# Overview
`GET /api/v1/scoreboard/stream` (`@Sse()` handler in
`backend/src/modules/system/system.controller.ts`) opens an SSE stream
that pushes the public scoreboard projection every time a new solve is
recorded.
There are **two** scoreboard-related SSE streams in the codebase:
# Push path
1. The **public** stream `GET /api/v1/scoreboard/stream`, owned by
`SystemController`
(`backend/src/modules/system/system.controller.ts`), is the
`@Public()` `@Sse('scoreboard/stream')` handler that pushes a
flattened solve payload every time the scoreboard hub publishes
a new solve. It is meant for unauthenticated spectators (e.g.
landing-page leaderboard widgets).
2. The **authenticated** combined stream `GET /api/v1/events`
(`backend/src/modules/challenges/events.controller.ts`) merges
`status` ticks, `general` settings pushes, and `solve` frames
into a single JWT-gated transport. The
[Scoreboard Page](/guides/scoreboard-page.md) subscribes here
and reacts to the `solve` event type to mutate all four tabs
in real time.
1. The challenge solve path (admin validation, player submit) updates the
`solve` table.
2. The same code path calls `SseHubService.publish('scoreboard', payload)`
# Public endpoint — `GET /api/v1/scoreboard/stream`
The handler
(`backend/src/modules/system/system.controller.ts`) opens an SSE
stream that pushes a flattened solve payload every time a new solve
is published to the scoreboard hub.
# Push path (both streams)
1. The challenge solve path (admin validation, player submit via
`ChallengesService.submitFlag`) inserts a new `solve` row.
2. The same code path calls
`SseHubService.publish('scoreboard', payload)`
(`backend/src/common/services/sse-hub.service.ts`).
3. Every subscriber to `/api/v1/scoreboard/stream` receives the payload.
3. Subscribers to **either** stream receive the payload:
* `/api/v1/scoreboard/stream` emits a flattened shape
(`challengeId`, `userId`, `pointsAwarded`, `rankBonus`,
`solvedAt`) for anonymous spectators.
* `/api/v1/events` emits a richer `SolveEventPayload` (with both
snake_case and camelCase aliases, plus `position`,
`live_points`, `solve_count`, etc.) for the authenticated SPA.
The hub is in-process; multi-replica deployments need a shared pub/sub
to fan out across pods (not in scope for this repo).
The hub is in-process; multi-replica deployments need a shared
pub/sub to fan out across pods (not in scope for this repo).
# Frontend consumer
# Authenticated consumer — `/scoreboard` page
The authenticated SPA shell subscribes on mount and updates the scoreboard
view whenever a new event arrives. The exact rendering lives inside the
authenticated shell's scoreboard component (see
[Frontend Structure](/architecture/frontend-structure.md)).
The `ScoreboardPage` smart component subscribes to `/api/v1/events`
(not the public stream) on `ngOnInit`:
1. `store.loadAll()` fetches the four projections from
`/api/v1/scoreboard/{ranking,matrix,event-log,graph}` in
parallel and populates the initial state.
2. `store.wireSse(() => authenticatedEventSource.open('/api/v1/events'))`
opens the authenticated SSE transport via
`AuthenticatedEventSourceService` (which adds the Bearer token
and the `'unauthorized'` `401`/`403` channel).
3. The store listens for the `solve` event type and mutates all
four tabs (Ranking, Matrix, Event Log, Score Graph) using pure
helpers in `scoreboard.pure.ts`
(`parseSolveEventIntoRanking`, `mutateMatrixFromSolve`,
`applySolveToGraph`, `dedupEventLogBySolveId`).
4. On transport error the store schedules a reconnect with an
exponential backoff (1s → 30s cap); the `solve` event type
resumes mutation as soon as the stream is back.
5. On `ngOnDestroy`, `store.stop()` aborts the transport and
clears the reconnect timer.
See [Scoreboard Page](/guides/scoreboard-page.md) for the full
tester-facing contract (tabs, test IDs, empty states, error states)
and [Challenges Endpoints](/api/challenges.md) for the request and
response shapes.
# See also
- [Scoreboard Page](/guides/scoreboard-page.md)
- [Event Window](/guides/event-window.md)
- [System Endpoints](/api/system.md)
- [Challenges Endpoints](/api/challenges.md)
+6 -2
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls.
The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-23T04:05:00Z.
they need. Last regenerated 2026-07-23T05:10:00Z.
# Architecture
@@ -116,7 +116,11 @@ they need. Last regenerated 2026-07-23T04:05:00Z.
* [Event LED Color Mapping](/guides/event-led-colors.md) - Source of
truth for the shell status dot's color in each `EventState`.
* [Scoreboard Stream](/guides/scoreboard-stream.md) - How live solves are
broadcast to clients.
broadcast to clients (public unauthenticated stream + authenticated
`/api/v1/events`).
* [Scoreboard Page](/guides/scoreboard-page.md) - How a signed-in player
navigates `/scoreboard`, uses the 4 tabs (Ranking, Matrix, Event Log,
Score Graph), and watches live solve updates.
* [Notifications](/guides/notifications.md) - The root notification
store + global HTTP error interceptor that translate backend error
codes and statuses into friendly user-facing messages.