docs: update documentation to OKF v0.1 format

This commit is contained in:
OpenVelo Agent
2026-07-23 05:13:20 +00:00
parent 49d0930780
commit 1576d663d3
6 changed files with 536 additions and 28 deletions
+170 -2
View File
@@ -2,8 +2,8 @@
type: api 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, sse, submit] tags: [api, challenges, board, score, scoreboard, sse, submit]
timestamp: 2026-07-23T00:10:00Z timestamp: 2026-07-23T05:10:00Z
--- ---
# Endpoints # Endpoints
@@ -21,6 +21,10 @@ are not `@Public()`). The DTOs and zod schemas live in
| `GET` | `/api/v1/challenges/:id` | Authenticated | Same. | | `GET` | `/api/v1/challenges/:id` | Authenticated | Same. |
| `POST` | `/api/v1/challenges/:id/solves` | Authenticated | Same. | | `POST` | `/api/v1/challenges/:id/solves` | Authenticated | Same. |
| `SSE` | `/api/v1/events` | Authenticated (SSE) | `backend/src/modules/challenges/events.controller.ts` | | `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 > The combined `/api/v1/events` SSE is **in addition to** the existing
> `/api/v1/events/status` status-only stream. The two share the same > `/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`, `challenge_id` or `challengeId`, `player_id` or `userId`,
`awarded_points` or `pointsAwarded`). `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 # Errors
The challenges endpoints use three additional error codes from 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) - [Challenge Tables](/database/challenges.md)
- [System Endpoints](/api/system.md) (legacy `/events/status` and `/event/stream`) - [System Endpoints](/api/system.md) (legacy `/events/status` and `/event/stream`)
- [Scoreboard Stream](/guides/scoreboard-stream.md) - [Scoreboard Stream](/guides/scoreboard-stream.md)
- [Scoreboard Page Guide](/guides/scoreboard-page.md)
- [Challenges Board Guide](/guides/challenges-board.md) - [Challenges Board Guide](/guides/challenges-board.md)
- [Backend Module Map](/architecture/backend-modules.md) - [Backend Module Map](/architecture/backend-modules.md)
+10 -3
View File
@@ -2,8 +2,8 @@
type: architecture type: architecture
title: Frontend Structure title: Frontend Structure
description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport. description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport.
tags: [architecture, frontend, angular, shell, sse, challenges, notifications] tags: [architecture, frontend, angular, shell, sse, challenges, scoreboard, notifications]
timestamp: 2026-07-23T00:10:00Z timestamp: 2026-07-23T05:10:00Z
--- ---
# Routes # Routes
@@ -16,7 +16,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
| `/login` | `LandingComponent` | `landingGuard` | Public landing and authentication modal. | | `/login` | `LandingComponent` | `landingGuard` | Public landing and authentication modal. |
| `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. | | `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. |
| `/challenges` | `ChallengesPage` | inherited | Full challenges board (category columns, modal, flag submit, live SSE). | | `/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. | | `/blog` | `BlogPage` | inherited | Placeholder blog page. |
| `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. | | `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. |
| `/admin/challenges` | `AdminChallengesComponent` | inherited | Sortable/searchable admin Challenges list, Add/Edit modal, import/export. | | `/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`. | | `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`). | | `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. | | `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. | | `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). | | `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) - [Authenticated Shell](/guides/authenticated-shell.md)
- [Event Window](/guides/event-window.md) - [Event Window](/guides/event-window.md)
- [Challenges Board](/guides/challenges-board.md) - [Challenges Board](/guides/challenges-board.md)
- [Scoreboard Page](/guides/scoreboard-page.md)
- [Notifications](/guides/notifications.md) - [Notifications](/guides/notifications.md)
- [System Overview](/architecture/overview.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/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/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/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/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`). | | `backend/src/common/errors/error-codes.ts` | Canonical error code map (now includes `EVENT_NOT_RUNNING`, `FLAG_INCORRECT`, `CHALLENGE_DISABLED`). |
# Frontend # 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/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-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/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/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`). | | `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`). | | `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-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-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/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 # See also
@@ -98,5 +115,6 @@ timestamp: 2026-07-23T04:05:00Z
- [Authenticated Shell](/guides/authenticated-shell.md) - [Authenticated Shell](/guides/authenticated-shell.md)
- [System Endpoints](/api/system.md) - [System Endpoints](/api/system.md)
- [Challenges Endpoints](/api/challenges.md) - [Challenges Endpoints](/api/challenges.md)
- [Scoreboard Page Guide](/guides/scoreboard-page.md)
- [Challenges Board](/guides/challenges-board.md) - [Challenges Board](/guides/challenges-board.md)
- [Notifications](/guides/notifications.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 type: guide
title: Scoreboard Stream title: Scoreboard Stream
description: How live solves are broadcast to the SPA over Server-Sent Events. 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] tags: [guide, scoreboard, sse, stream, public, authenticated]
timestamp: 2026-07-21T18:28:00Z timestamp: 2026-07-23T05:10:00Z
--- ---
# Endpoint # Overview
`GET /api/v1/scoreboard/stream` (`@Sse()` handler in There are **two** scoreboard-related SSE streams in the codebase:
`backend/src/modules/system/system.controller.ts`) opens an SSE stream
that pushes the public scoreboard projection every time a new solve is
recorded.
# 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 # Public endpoint — `GET /api/v1/scoreboard/stream`
`solve` table.
2. The same code path calls `SseHubService.publish('scoreboard', payload)` 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`). (`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 The hub is in-process; multi-replica deployments need a shared
to fan out across pods (not in scope for this repo). 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 The `ScoreboardPage` smart component subscribes to `/api/v1/events`
view whenever a new event arrives. The exact rendering lives inside the (not the public stream) on `ngOnInit`:
authenticated shell's scoreboard component (see
[Frontend Structure](/architecture/frontend-structure.md)). 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 # See also
- [Scoreboard Page](/guides/scoreboard-page.md)
- [Event Window](/guides/event-window.md) - [Event Window](/guides/event-window.md)
- [System Endpoints](/api/system.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. 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-23T04:05:00Z. they need. Last regenerated 2026-07-23T05:10:00Z.
# Architecture # 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 * [Event LED Color Mapping](/guides/event-led-colors.md) - Source of
truth for the shell status dot's color in each `EventState`. truth for the shell status dot's color in each `EventState`.
* [Scoreboard Stream](/guides/scoreboard-stream.md) - How live solves are * [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 * [Notifications](/guides/notifications.md) - The root notification
store + global HTTP error interceptor that translate backend error store + global HTTP error interceptor that translate backend error
codes and statuses into friendly user-facing messages. codes and statuses into friendly user-facing messages.