292 lines
14 KiB
Markdown
292 lines
14 KiB
Markdown
---
|
||
type: guide
|
||
title: Scoreboard Page
|
||
description: How a signed-in player navigates /scoreboard, uses the four tabs, distinguishes up to ten visible players by collision-resolved colors, and watches live solve updates.
|
||
tags: [guide, scoreboard, sse, live, ranking, matrix, event-log, score-graph, tester]
|
||
timestamp: 2026-07-23T06:01:38Z
|
||
---
|
||
|
||
# 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 from the 10-entry
|
||
`PLAYER_COLOR_PALETTE`. `stablePlayerColorIndex` supplies the preferred
|
||
color; `assignUniquePlayerColors` keeps that color when available and
|
||
linearly selects the next free palette slot after a collision. Ranking,
|
||
Matrix, and Score Graph apply this assignment in their current display
|
||
order after initial REST loading and after live updates, so up to ten
|
||
simultaneously displayed players remain visually distinct within each
|
||
projection.
|
||
* 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. The top ten visible series
|
||
receive distinct palette colors, including when two player IDs hash to
|
||
the same preferred color.
|
||
* 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`.
|
||
Colors are reassigned in sorted ranking order to avoid palette collisions.
|
||
* **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, then visible row colors
|
||
are collision-resolved in matrix order.
|
||
* **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. The SSE solve frame carries
|
||
`challenge_name` / `category_abbreviation` (sourced from the same
|
||
`challenge` + `category` join that backs the
|
||
`/api/v1/scoreboard/event-log` REST projection), so the prepended
|
||
row's category abbreviation and challenge name are populated
|
||
immediately — no refresh is required.
|
||
* **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. Those ten series are then assigned unique palette slots in
|
||
graph order.
|
||
|
||
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.
|
||
|
||
## Verifying collision-resolved player colors
|
||
|
||
1. Seed or create up to ten visible players, including player IDs whose
|
||
preferred palette hashes collide.
|
||
2. Open `/scoreboard` and inspect **Ranking**. Every one of the first ten
|
||
displayed players has a distinct swatch; players without a collision keep
|
||
their preferred hashed color.
|
||
3. Open **Matrix**. Up to ten visible player rows likewise use distinct
|
||
swatches in matrix row order.
|
||
4. Open **Score Graph**. Its ten legend swatches and matching polylines use
|
||
ten distinct palette colors.
|
||
5. Trigger a solve. Ranking and graph order may change, but each projection
|
||
resolves its displayed colors again and remains collision-free for up to
|
||
ten players.
|
||
|
||
# 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. |