--- 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, watches live solve updates, and sees safe empty output for invalid event windows. tags: [guide, scoreboard, sse, live, ranking, matrix, event-log, score-graph, tester] timestamp: 2026-07-23T06:39:17Z --- # 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 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--"` 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. 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-"`) 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` | * The graph validates that `endUtc` is strictly after `startUtc` and that both timestamps are finite. If the event window is missing, reversed, equal, or otherwise invalid, no SVG polyline coordinates are emitted and the graph remains empty rather than rendering off-canvas or non-finite points. * Individual invalid solve timestamps are projected at the left edge of the plot; all generated coordinates remain finite and within the SVG plot bounds. # Technical wiring | File | Responsibility | |---|---| | `frontend/src/app/features/scoreboard/score-graph.component.ts` | Renders the graph state, legend, axes, and SVG polylines; suppresses series rendering when the event window is invalid. | | `frontend/src/app/features/scoreboard/scoreboard.pure.ts` | Defines graph view types, validates event windows, projects timestamps and values into bounded SVG coordinates, and applies live solve updates. | | `frontend/src/app/features/scoreboard/scoreboard.store.ts` | Loads REST projections, assigns collision-free colors, consumes authenticated `solve` SSE frames, and updates the graph signal. | | `tests/frontend/score-graph-projection.spec.ts` | Covers reversed, equal, missing, and malformed event windows plus finite/in-bounds graph coordinates. | # Examples ## Invalid event-window behavior 1. Configure an event with `endUtc` before or equal to `startUtc`, or provide malformed timestamps. 2. Open `/scoreboard` and select **Score Graph**. 3. Verify that no off-canvas polyline is drawn. The graph displays `No solves yet` when the state is otherwise active, or `Awaiting event configuration` when the event is unconfigured. # 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 `[, ]` 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 `