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:
+170
-2
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 1–3 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)
|
||||
|
||||
@@ -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 1–3, 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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user