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
+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.