--- 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-"` and a `data-rank=""` 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--"` 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-"` and `data-testid="matrix-col-"` 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 `
    ` (`data-testid="scoreboard-event-log"`) capped at 200 entries: * Each `
  1. ` has `data-testid="event-log-row-"`. * 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. * `+` 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-"`) lists each player with their colored swatch. * Each player has a `polyline` element (`data-testid="score-graph-line-"`) 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 `[, ]` 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 `