From 2d13080dd8a656dac2c44dd7c9b5dfcbff28be14 Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Thu, 23 Jul 2026 06:03:11 +0000 Subject: [PATCH 1/2] docs: update documentation to OKF v0.1 format --- docs/guides/scoreboard-page.md | 41 +++++++++++++++++++++++++++------- docs/index.md | 6 ++--- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/docs/guides/scoreboard-page.md b/docs/guides/scoreboard-page.md index 692b58f..59cb275 100644 --- a/docs/guides/scoreboard-page.md +++ b/docs/guides/scoreboard-page.md @@ -1,9 +1,9 @@ --- 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. +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-23T05:41:00Z +timestamp: 2026-07-23T06:01:38Z --- # Overview @@ -58,9 +58,14 @@ A table (`data-testid="scoreboard-ranking"`) with columns **Rank**, * 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. +* 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. @@ -121,7 +126,9 @@ 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. + 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. @@ -152,9 +159,11 @@ The same `solve` frame that lights up the `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. + 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 @@ -166,7 +175,8 @@ The same `solve` frame that lights up the * **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. + 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 @@ -256,6 +266,21 @@ DOM. 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. diff --git a/docs/index.md b/docs/index.md index 1866336..a5bd0db 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin controls. The docs below are organized by purpose so agents can pull just the slice -they need. Last regenerated 2026-07-23T05:41:00Z. +they need. Last regenerated 2026-07-23T06:01:38Z. # Architecture @@ -119,8 +119,8 @@ they need. Last regenerated 2026-07-23T05:41:00Z. broadcast to clients (public unauthenticated stream + authenticated `/api/v1/events`). * [Scoreboard Page](/guides/scoreboard-page.md) - How a signed-in player - navigates `/scoreboard`, uses the 4 tabs (Ranking, Matrix, Event Log, - Score Graph), and watches live solve updates. + navigates `/scoreboard`, uses the 4 tabs, verifies collision-resolved + colors for up to ten visible players, and watches live solve updates. * [Notifications](/guides/notifications.md) - The root notification store + global HTTP error interceptor that translate backend error codes and statuses into friendly user-facing messages. -- 2.52.0 From 66292b6ced939b426ff4fab912e4eb65a5562047 Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Thu, 23 Jul 2026 06:03:11 +0000 Subject: [PATCH 2/2] feat: Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.01 --- .kilo/plans/910.md | 39 ---- .kilo/plans/911.md | 202 ++++++++++++++++++ .../features/scoreboard/scoreboard.pure.ts | 35 ++- .../features/scoreboard/scoreboard.store.ts | 10 +- tests/frontend/scoreboard.pure.spec.ts | 88 ++++++++ 5 files changed, 329 insertions(+), 45 deletions(-) delete mode 100644 .kilo/plans/910.md create mode 100644 .kilo/plans/911.md diff --git a/.kilo/plans/910.md b/.kilo/plans/910.md deleted file mode 100644 index 943b4a1..0000000 --- a/.kilo/plans/910.md +++ /dev/null @@ -1,39 +0,0 @@ -# Implementation Plan: Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.00 - -## 1. Architectural Reconnaissance -- **Codebase style & conventions:** Node.js npm-workspace monorepo with a NestJS 10 backend and Angular 17 standalone frontend, both written in TypeScript. Backend request/business logic is service-oriented; live updates use RxJS-backed NestJS SSE. Frontend feature state uses private Angular signals exposed as read-only signals, with SSE frames normalized in `ScoreboardStore` and then applied immutably. The Event Log template already renders `categoryAbbreviation` and `challengeName`; the defect originates upstream because the solve publish path and authenticated SSE mapper omit those fields, and the frontend currently replaces them with empty strings. -- **Data Layer:** TypeORM with `better-sqlite3`. `ChallengesService.submitFlag` already joins `challenge` to `category`, so the successful transaction has `challenge.name` and `challenge.abbreviation` available without an additional query. The reloaded Event Log projection independently joins `solve`, `user`, `challenge`, and `category`, which explains why labels appear after refresh. No schema or migration is required. -- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; backend tests are under `tests/backend/` using Nest testing utilities/Supertest and frontend tests are under `tests/frontend/` using jsdom. All tests run from the repository root with `npm test`; focused suites can use `npm run test:backend` and `npm run test:frontend`. Keep regression coverage in the existing dedicated test folders, not beside source files. -- **Required Tools & Dependencies:** No new package, system tool, global CLI, persistent `/data` fixture, or `setup.sh` change is required. Existing Node/npm, TypeScript, Jest, NestJS, Angular, RxJS, TypeORM, and SQLite dependencies cover the implementation. Verification should use `npm test` and `npm run build`; the repository exposes no lint script. - -## 2. Impacted Files -- **To Modify:** - - `backend/src/modules/challenges/dto/challenges.dto.ts` — extend the typed live solve contract with `challengeName` and `categoryAbbreviation`. - - `backend/src/modules/challenges/challenges.service.ts` — include challenge/category labels in the post-insert hub payload using metadata already loaded by `submitFlag`. - - `backend/src/modules/challenges/events.controller.ts` — forward the two labels in authenticated solve SSE frames, preserving the established snake_case wire format and accepting current camelCase hub fields. - - `frontend/src/app/features/scoreboard/scoreboard.pure.ts` — extend `SolveLivePayload` so the normalized live event carries the Event Log labels. - - `frontend/src/app/features/scoreboard/scoreboard.store.ts` — parse label fields from SSE aliases and populate the newly prepended `EventLogRow` instead of writing empty strings. - - `tests/backend/challenges-submit-flag.spec.ts` — strengthen the existing hub-publish success-path test to assert challenge name and category abbreviation. - - `tests/frontend/scoreboard.store.spec.ts` — add focused regression assertions proving an SSE-pushed Event Log row retains both labels. - - `docs/api/challenges.md` — update the documented authenticated solve-frame contract and example fields. - - `docs/guides/scoreboard-stream.md` — document that live solve frames carry display metadata needed by the Event Log. -- **To Create:** None. - -## 3. Proposed Changes -1. **Database / Schema Migration:** Make no database changes. Reuse the challenge/category metadata already selected in `ChallengesService.submitFlag`; keep the existing reload query in `ScoreboardService.getEventLog` unchanged. -2. **Backend Logic & APIs:** - 1. Add required `challengeName` and `categoryAbbreviation` properties to `SolveEventPayload` so omissions are caught at the backend contract boundary. - 2. Extend the private `publishSolveEvent` input and its `SseHubService.emitScoreboard` payload with `challenge.name` and `challenge.abbreviation`, passing them from the successful fresh-solve branch only. Preserve current idempotency: `already_solved`, failed validation, and uniqueness-race fallback paths must not publish a new event. - 3. Extend `ChallengesEventsController.solveFrame` to emit `challenge_name` and `category_abbreviation` alongside `challenge_id`, player, scoring, and timing data. Do not add a lookup or business logic to the controller; it should remain a pure transport mapping over the enriched hub payload. - 4. Keep all existing fields and aliases intact so Ranking, Matrix, Graph, Challenges Board, and any current stream consumers remain backward-compatible. -3. **Frontend UI Integration:** - 1. Extend `SolveLivePayload` with `challengeName` and `categoryAbbreviation`. - 2. In `ScoreboardStore.handleSseFrame`, normalize both snake_case fields (`challenge_name`, `category_abbreviation`) and camelCase fallbacks (`challengeName`, `categoryAbbreviation`) in the same style as existing solve properties. - 3. In `applySolveEvent`, copy those normalized values into the new `EventLogRow`. Leave `EventLogComponent` unchanged because its `.what` span already renders the abbreviation followed by challenge name, and leave the existing deduplication/cap behavior unchanged. - 4. Update API/stream documentation so the public contract matches the corrected wire payload and explains that both initial REST rows and live SSE rows contain identical display metadata. - -## 4. Test Strategy -- **Target Unit Test File:** - - `tests/backend/challenges-submit-flag.spec.ts`: enhance the existing “emits an SSE solve payload” test to assert the emitted hub event contains the seeded challenge name and `CRY` abbreviation. This directly verifies the root publish boundary without adding a large SSE/network fixture. - - `tests/frontend/scoreboard.store.spec.ts`: extend the existing live solve-frame test, or add one minimal test, with `challenge_name` and `category_abbreviation`, then assert the prepended Event Log row contains both exact values while retaining awarded points. This covers the user-visible regression through the store’s SSE normalization path without UI/visual testing. -- **Mocking Strategy:** Reuse the existing in-memory SQLite Nest application and real `SseHubService` subscription in the backend test; no external service mocks or persistent files are needed. Reuse `FakeApi`, the fake `DestroyRef`, and the lightweight fake `EventSourceLike` in the frontend test; feed a synthetic `MessageEvent` and inspect the public read-only `eventLog` signal. Do not add Playwright, browser, visual, filesystem, network, or `/data` fixtures. Implement test-first: make the focused assertions fail against the current omission, then apply the smallest payload-contract changes and run the root `npm test` plus `npm run build`. diff --git a/.kilo/plans/911.md b/.kilo/plans/911.md new file mode 100644 index 0000000..7aaeee6 --- /dev/null +++ b/.kilo/plans/911.md @@ -0,0 +1,202 @@ +# Implementation Plan: Scoreboard — Distinct Line Colors per Top-10 Player on Score Graph + +## Status + +**Feature NOT yet implemented.** The `PLAYER_COLOR_PALETTE` exposes 10 distinct +hex colors and there are 10 legend entries / 10 polylines, but each `colorIndex` +is derived from `stablePlayerColorIndex(playerId)` — an FNV-1a hash modulo 10. +For many player-id sets (including the reported one) that hash produces +collisions: e.g. `PlayerC` and `Player05` both hash to `5` → `#ec4899`; +`Player07` and `Player08` both hash to `9` → `#84cc16`; `PlayerA` and +`Player10` both hash to `2` → `#10b981`. Verified empirically: + +``` +hash → palette index +PlayerA → 2 (#10b981) green +PlayerB → 8 (#6366f1) +PlayerC → 5 (#ec4899) pink ← collides with Player05 +Player04 → 8 (#6366f1) +Player05 → 5 (#ec4899) pink ← collides with PlayerC +Player06 → 3 (#ef4444) +Player07 → 9 (#84cc16) lime ← collides with Player08 +Player08 → 9 (#84cc16) lime ← collides with Player07 +Player09 → 3 (#ef4444) +Player10 → 2 (#10b981) green ← collides with PlayerA +``` + +Collisions are observed across **all four** scoreboard tabs (Ranking swatch, +Matrix first-column swatch, Event Log has no swatch, Score Graph swatch + +polyline) because every component reads its color through the same +`colorIndex → PLAYER_COLOR_PALETTE` lookup. + +The Job text limits the symptom to the Score Graph, but the root cause is +shared data (`colorIndex`), so the fix must be applied at the data layer +(`scoreboard.pure.ts`) and re-projected through `scoreboard.store.ts` so the +same top-10 player keeps the same distinct color in every tab. + +## 1. Architectural Reconnaissance + +- **Framework & language:** TypeScript strict mode, Angular standalone + components, NestJS backend (irrelevant for this fix — the bug is purely + client-side rendering). +- **Code style:** Standalone components with `ChangeDetectionStrategy.OnPush`, + signal-based store, pure-function helpers in `scoreboard.pure.ts` (no + Angular DI), services in `*.service.ts`, store in `*.store.ts`. +- **Data layer:** Pure-function projections over server-supplied payloads. + The four tabs are populated from + `/api/v1/scoreboard/{ranking,matrix,event-log,graph}` (parallel fetch in + `ScoreboardStore.loadAll()`) and incrementally patched in + `ScoreboardStore.applySolveEvent()` from the `/api/v1/events` SSE + `solve` frame. +- **Color model:** A single readonly 10-entry hex array + `PLAYER_COLOR_PALETTE` in `frontend/src/app/features/scoreboard/scoreboard.pure.ts:73` + and a hashing helper `stablePlayerColorIndex(playerId)` + (`scoreboard.pure.ts:86`). Every consumer (ranking, matrix, score-graph) + reads `PLAYER_COLOR_PALETTE[idx]` directly. There is currently **no** + collision-resolution pass. +- **Test framework & structure:** Jest 29 with `ts-jest` preset, configured + by `/repo/tests/jest.config.js` (two projects: `backend` and `frontend`, + the latter with `jsdom`). Tests live exclusively in `/repo/tests/frontend/` + and `/repo/tests/backend/` (never next to source). Single command: + `npm test` from the repo root. +- **Required tools & dependencies:** None new. The fix is purely a + pure-function rewrite inside existing files plus a tiny test addition. + No `setup.sh` change, no new packages. + +## 2. Impacted Files + +- **To Modify:** + - `frontend/src/app/features/scoreboard/scoreboard.pure.ts` — add a + collision-free top-N palette assigner; rewire the three projection + helpers (`parseSolveEventIntoRanking`, `mutateMatrixFromSolve`, + `applySolveToGraph`) so they use it instead of bare + `stablePlayerColorIndex`. + - `frontend/src/app/features/scoreboard/scoreboard.store.ts` — apply + the unique-color projection after `loadAll()` populates each signal + (ranking/matrix/graph) so the initial REST responses also benefit. + - `tests/frontend/scoreboard.pure.spec.ts` — add focused specs covering + the new collision-free assigner (success path: distinct indices for + the documented colliding ID set) and a regression spec asserting + existing single-player behaviour still holds. + +- **To Create:** None. + +## 3. Proposed Changes + +### 3.1. Add a unique-color assigner in `scoreboard.pure.ts` + +Export a new pure helper next to `stablePlayerColorIndex`: + +```ts +export function assignUniquePlayerColors( + items: readonly T[], + getColorIndex: (t: T) => number, + capacity: number = PLAYER_COLOR_PALETTE.length, +): T[] { + const used = new Set(); + const out: T[] = []; + for (const item of items) { + const preferred = ((getColorIndex(item) % capacity) + capacity) % capacity; + let chosen = preferred; + if (used.has(preferred)) { + // Linear probe — the top-10 window guarantees a free slot exists. + let probe = (preferred + 1) % capacity; + while (used.has(probe) && probe !== preferred) { + probe = (probe + 1) % capacity; + } + chosen = probe; + } + used.add(chosen); + out.push({ ...item, colorIndex: chosen } as T); + } + return out; +} +``` + +Semantics: + +- The first player keeps their hashed index; collisions are pushed to the + next free slot via linear probe. With `capacity = 10` and at most 10 + players, a free slot is always found (the input length is itself + bounded to ≤ capacity by the upstream `top-10 cutoff` in + `applySolveToGraph`). +- Order is preserved → ranking/rank-cell order is unaffected. +- The mapper is injected so the same helper works for `RankingRow`, + `GraphSeries`, and any future list type that needs a `colorIndex`. + +### 3.2. Use the assigner inside the three projection helpers + +In `parseSolveEventIntoRanking`, replace the direct assignment at the +"new player" branch (currently +`colorIndex: stablePlayerColorIndex(payload.playerId)`) by: + +1. Build a temporary list of the affected/new player with the hashed index. +2. After the sort, call `assignUniquePlayerColors(sorted, (r) => r.colorIndex)` + and return that. + +In `mutateMatrixFromSolve`, when a brand-new player is appended, derive +their provisional `colorIndex` from the hash, then post-process the +`players` array with `assignUniquePlayerColors` before returning. + +In `applySolveToGraph`, after the new series is added and the array is +sorted + sliced to top 10, run `assignUniquePlayerColors` on the top-10 +series so the polyline colors are guaranteed unique. New players appended +after the cutoff don't affect the top-10. + +This keeps the FNV hash as the **deterministic preference** (so a +player retains their color across all tabs and across reloads whenever +possible) while guaranteeing uniqueness inside the top-10 window. + +### 3.3. Apply the same pass after `loadAll()` in `scoreboard.store.ts` + +The initial REST responses already arrive with `colorIndex` set by the +backend (which uses the same hashing convention). After the four +parallel fetches resolve, re-project each view through the new assigner +so the very first paint also shows distinct colors. Specifically, in +the success branch of `loadAll()`: + +```ts +this._ranking.set(assignUniquePlayerColors(ranking, (r) => r.colorIndex)); +const matrixPlayers = assignUniquePlayerColors(matrix.players, (p) => p.colorIndex); +this._matrix.set({ ...matrix, players: matrixPlayers }); +this._graph.set({ ...graph, series: assignUniquePlayerColors(graph.series, (s) => s.colorIndex) }); +``` + +(Backed by tests in §4; the projection is a no-op when there is only +one player or when no collisions exist.) + +### 3.4. Verify Ranking/Matrix tabs visually + +The existing `ranking.component.ts:68`, `matrix.component.ts:108`, and +`score-graph.component.ts:112` `color(idx)` helpers already guard +against out-of-range indices via the same modulo trick the assigner +uses, so no template changes are required — they keep working as soon as +`colorIndex` is unique. + +## 4. Test Strategy + +- **Target test file:** `tests/frontend/scoreboard.pure.spec.ts` + (and one regression test for `applySolveToGraph` covering the exact + Job example: ten PlayerC/Player05-style IDs). +- **Mocking strategy:** The helper is a pure function with no DOM, no + Angular DI, and no async — it is imported directly into the existing + spec file, which already mocks only `@angular/common/http` (no extra + setup needed). No `TestBed`, no fixtures, no chart rendering. +- **Spec list (minimal, focused):** + 1. `assignUniquePlayerColors` returns distinct indices for the + documented colliding id set + `['PlayerA','PlayerB','PlayerC','Player04','Player05','Player06','Player07','Player08','Player09','Player10']` + and the colors they map to are all different (assert via + `PLAYER_COLOR_PALETTE[idx]` set size === 10). + 2. `assignUniquePlayerColors` is a no-op when there are no collisions + (single player, or hashed indices already unique). + 3. `applySolveToGraph` end-to-end: feed it an empty view + 10 SSE + solves for 10 colliding players, then assert every series in the + resulting `view.series` has a unique `colorIndex` (regression for + the Job's DOM-inspection symptom at the polyline level). + 4. `parseSolveEventIntoRanking` regression: existing test still passes + — proves the rewire did not change ranking sort/points logic. + +- **How to run:** `npm test` from the repo root (resolves to the + pre-configured Jest project; frontend project selected automatically + because all impacted specs live under `tests/frontend/`). diff --git a/frontend/src/app/features/scoreboard/scoreboard.pure.ts b/frontend/src/app/features/scoreboard/scoreboard.pure.ts index 96e64c4..c72d09e 100644 --- a/frontend/src/app/features/scoreboard/scoreboard.pure.ts +++ b/frontend/src/app/features/scoreboard/scoreboard.pure.ts @@ -94,6 +94,32 @@ export function stablePlayerColorIndex(playerId: string): number { return ((hash % n) + n) % n; } +export function assignUniquePlayerColors( + items: readonly T[], + getColorIndex: (t: T) => number, + capacity: number = PLAYER_COLOR_PALETTE.length, +): T[] { + const used = new Set(); + const cap = Math.max(1, Math.floor(capacity)); + const out: T[] = new Array(items.length); + for (let i = 0; i < items.length; i += 1) { + const item = items[i]; + const preferredRaw = getColorIndex(item); + const preferred = ((preferredRaw % cap) + cap) % cap; + let chosen = preferred; + if (used.has(preferred)) { + let probe = (preferred + 1) % cap; + while (used.has(probe) && probe !== preferred) { + probe = (probe + 1) % cap; + } + chosen = probe; + } + used.add(chosen); + out[i] = { ...item, colorIndex: chosen } as T; + } + return out; +} + export function applyRankingSort(rows: readonly RankingRow[]): RankingRow[] { const list = rows.map((r) => ({ ...r })); list.sort((a, b) => { @@ -136,7 +162,8 @@ export function parseSolveEventIntoRanking( colorIndex: stablePlayerColorIndex(payload.playerId), }); } - return applyRankingSort(out); + const sorted = applyRankingSort(out); + return assignUniquePlayerColors(sorted, (r) => r.colorIndex); } export function dedupEventLogBySolveId( @@ -221,8 +248,9 @@ export function applySolveToGraph( const finalValue = (s: GraphSeries) => s.points[s.points.length - 1]?.value ?? 0; seriesList.sort((a, b) => finalValue(b) - finalValue(a)); const top = seriesList.slice(0, 10); + const uniqueTop = assignUniquePlayerColors(top, (s) => s.colorIndex); - return { startUtc: start, endUtc: end, serverNowUtc, state, series: top }; + return { startUtc: start, endUtc: end, serverNowUtc, state, series: uniqueTop }; } export function mutateMatrixFromSolve( @@ -251,7 +279,8 @@ export function mutateMatrixFromSolve( cellRow[payload.challengeId] = 'solved'; } cells[payload.playerId] = cellRow; - return { players, challenges: matrix.challenges, cells }; + const uniquePlayers = assignUniquePlayerColors(players, (p) => p.colorIndex); + return { players: uniquePlayers, challenges: matrix.challenges, cells }; } export function formatSolveDateTime(utc: string | null | undefined): string { diff --git a/frontend/src/app/features/scoreboard/scoreboard.store.ts b/frontend/src/app/features/scoreboard/scoreboard.store.ts index 1456f92..f0a9334 100644 --- a/frontend/src/app/features/scoreboard/scoreboard.store.ts +++ b/frontend/src/app/features/scoreboard/scoreboard.store.ts @@ -9,6 +9,7 @@ import { ScoreboardTab, SolveLivePayload, applySolveToGraph, + assignUniquePlayerColors, dedupEventLogBySolveId, mutateMatrixFromSolve, parseSolveEventIntoRanking, @@ -67,10 +68,13 @@ export class ScoreboardStore { const matrix = await this.toPromise(() => this.api.getMatrix()); const eventLog = await this.toPromise(() => this.api.getEventLog(50)); const graph = await this.toPromise(() => this.api.getGraph()); - this._ranking.set(ranking); - this._matrix.set(matrix); + const uniqueRanking = assignUniquePlayerColors(ranking, (r) => r.colorIndex); + const uniqueMatrixPlayers = assignUniquePlayerColors(matrix.players, (p) => p.colorIndex); + const uniqueGraphSeries = assignUniquePlayerColors(graph.series, (s) => s.colorIndex); + this._ranking.set(uniqueRanking); + this._matrix.set({ ...matrix, players: uniqueMatrixPlayers }); this._eventLog.set(eventLog); - this._graph.set(graph); + this._graph.set({ ...graph, series: uniqueGraphSeries }); } catch (err: any) { this._error.set(err?.message ?? 'Failed to load scoreboard'); } finally { diff --git a/tests/frontend/scoreboard.pure.spec.ts b/tests/frontend/scoreboard.pure.spec.ts index d78deff..d3a5b79 100644 --- a/tests/frontend/scoreboard.pure.spec.ts +++ b/tests/frontend/scoreboard.pure.spec.ts @@ -8,6 +8,7 @@ import { RankingRow, applyRankingSort, applySolveToGraph, + assignUniquePlayerColors, dedupEventLogBySolveId, formatSolveDateTime, mutateMatrixFromSolve, @@ -25,6 +26,52 @@ describe('stablePlayerColorIndex', () => { }); }); +describe('assignUniquePlayerColors', () => { + const ids = [ + 'PlayerA', + 'PlayerB', + 'PlayerC', + 'Player04', + 'Player05', + 'Player06', + 'Player07', + 'Player08', + 'Player09', + 'Player10', + ]; + + it('gives the documented colliding id set 10 distinct palette colors', () => { + const rows: RankingRow[] = ids.map((playerId) => ({ + rank: 0, + playerId, + playerName: playerId, + solvedCount: 1, + points: 0, + colorIndex: stablePlayerColorIndex(playerId), + })); + const unique = assignUniquePlayerColors(rows, (r) => r.colorIndex); + expect(unique.length).toBe(ids.length); + const paletteHits = new Set(unique.map((r) => PLAYER_COLOR_PALETTE[r.colorIndex])); + expect(paletteHits.size).toBe(PLAYER_COLOR_PALETTE.length); + unique.forEach((r) => { + expect(r.colorIndex).toBeGreaterThanOrEqual(0); + expect(r.colorIndex).toBeLessThan(PLAYER_COLOR_PALETTE.length); + }); + }); + + it('keeps the hashed index when there is no collision', () => { + const rows: RankingRow[] = [ + { rank: 0, playerId: 'p1', playerName: 'p1', solvedCount: 1, points: 10, colorIndex: stablePlayerColorIndex('p1') }, + { rank: 0, playerId: 'p2', playerName: 'p2', solvedCount: 1, points: 5, colorIndex: stablePlayerColorIndex('p2') }, + ]; + const hashed1 = rows[0].colorIndex; + const hashed2 = rows[1].colorIndex; + const unique = assignUniquePlayerColors(rows, (r) => r.colorIndex); + expect(unique[0].colorIndex).toBe(hashed1); + expect(unique[1].colorIndex).toBe(hashed2); + }); +}); + describe('applyRankingSort', () => { it('produces competition ranks with zero-pt rows at the bottom', () => { const rows: RankingRow[] = [ @@ -114,6 +161,47 @@ describe('applySolveToGraph', () => { ); expect(next.series).toEqual([]); }); + + it('yields 10 unique polyline colors for the documented colliding id set', () => { + const ids = [ + 'PlayerA', + 'PlayerB', + 'PlayerC', + 'Player04', + 'Player05', + 'Player06', + 'Player07', + 'Player08', + 'Player09', + 'Player10', + ]; + const start = '2025-01-01T00:00:00.000Z'; + const end = '2025-01-02T00:00:00.000Z'; + let view: any = null; + ids.forEach((playerId, idx) => { + const awarded = 100 - idx * 5; + view = applySolveToGraph( + view, + { + challengeId: `c${idx}`, + challengeName: `ch${idx}`, + categoryAbbreviation: 'CRY', + playerId, + playerName: playerId, + awardedPoints: awarded, + rankBonus: 0, + awardedAtUtc: `2025-01-01T01:00:0${idx}.000Z`, + position: idx + 1, + }, + start, + end, + '2025-01-01T12:00:00.000Z', + ); + }); + expect(view.series.length).toBe(10); + const paletteHits = new Set(view.series.map((s: any) => PLAYER_COLOR_PALETTE[s.colorIndex])); + expect(paletteHits.size).toBe(10); + }); }); describe('mutateMatrixFromSolve', () => { -- 2.52.0