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:
@@ -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.
|
||||
Reference in New Issue
Block a user