diff --git a/.kilo/plans/865-matrix-solved-sentinel.md b/.kilo/plans/865-matrix-solved-sentinel.md
deleted file mode 100644
index 1439495..0000000
--- a/.kilo/plans/865-matrix-solved-sentinel.md
+++ /dev/null
@@ -1,162 +0,0 @@
-# Plan: Scoreboard Matrix — Distinguish "solved (not top-3)" from "not solved"
-
-## Goal
-In the Matrix view, a cell must render a green checkmark for *any* solve
-(positions 1, 2, 3, **and** 4+) and stay empty only when the player
-genuinely has not solved the challenge. Today the backend stores
-positions > 3 as `null` (skipped at
-`backend/src/modules/challenges/scoreboard.service.ts:87`), so the
-frontend can't tell "solved but not top-3" apart from "never solved".
-We introduce a `'solved'` sentinel so the frontend can always render
-✓ for a real solve.
-
-## 1. Type changes
-
-### 1.1 Backend
-`backend/src/modules/challenges/dto/scoreboard.dto.ts:21`:
-```ts
-export type MatrixCellRank = 1 | 2 | 3 | null;
-```
-→
-```ts
-export type MatrixCellRank = 1 | 2 | 3 | 'solved' | null;
-```
-
-### 1.2 Frontend
-`frontend/src/app/features/scoreboard/scoreboard.pure.ts:27`:
-```ts
-export type MatrixCellRank = 1 | 2 | 3 | null;
-```
-→ identical widening to `1 | 2 | 3 | 'solved' | null`.
-
-Both types must stay in lock-step so the controller JSON, the store,
-and the matrix component all agree on the new sentinel.
-
-## 2. Backend projection
-
-`backend/src/modules/challenges/scoreboard.service.ts:83-96` — the
-per-challenge solver walk currently does:
-```ts
-for (let i = 0; i < list.length; i += 1) {
- const position = i + 1;
- if (position > 3) continue; // <- drops positions > 3
- ...
- perPlayer.set(challengeId, position as MatrixCellRank);
-}
-```
-Replace the `if (position > 3) continue;` with:
-```ts
-const value: MatrixCellRank = position <= 3 ? (position as 1|2|3) : 'solved';
-perPlayer.set(challengeId, value);
-```
-Everything else in this loop (player-row creation, map writes) stays
-unchanged. The cells map therefore now contains a row for every
-solver of every challenge, with `1|2|3|'solved'|null`.
-
-## 3. Frontend live mutation
-
-`frontend/src/app/features/scoreboard/scoreboard.pure.ts:246-248`
-(`mutateMatrixFromSolve`) — currently only writes for positions 1-3:
-```ts
-if (payload.position >= 1 && payload.position <= 3) {
- cellRow[payload.challengeId] = payload.position as MatrixCellRank;
-}
-```
-Replace with:
-```ts
-if (payload.position >= 1 && payload.position <= 3) {
- cellRow[payload.challengeId] = payload.position as 1 | 2 | 3;
-} else if (payload.position >= 4) {
- cellRow[payload.challengeId] = 'solved';
-}
-```
-(no else branch — for `position <= 0` we leave the cell alone, same
-as today.) With this, a 4th-or-later live solve correctly flips a
-blank cell to the green ✓.
-
-## 4. Frontend rendering
-
-`frontend/src/app/features/scoreboard/matrix.component.ts:86-95` —
-currently the template uses `*ngSwitchDefault` for non-top-3 cells
-and re-derives "is this player solved" via a helper method. Now the
-sentinel tells us directly:
-```html
-
- ★
- ★
- ★
- ✓
-
-
-```
-Changes vs today:
-- Add an explicit `*ngSwitchCase="'solved'"` branch that renders ✓
- with `class="cell-check"` (the green color already defined at
- `matrix.component.ts:32`).
-- Remove the `*ngSwitchDefault` body (which used `cellSolved()` and a
- nested ``). With the sentinel there is no
- ambiguity — `null` means empty, `'solved'` means checkmark.
-- The `cellSolved(playerId, challengeId)` helper method on
- `MatrixComponent` becomes dead code (its only caller is the
- template branch we removed) → delete it.
-
-## 5. Test updates
-
-### 5.1 `tests/backend/scoreboard.service.spec.ts`
-Update the matrix assertion at the "marks top-3 positions" case —
-currently:
-```ts
-expect(matrix.cells['u-d']?.['c1']).toBeNull();
-```
-Change to:
-```ts
-expect(matrix.cells['u-d']?.['c1']).toBe('solved');
-```
-(u-d is the 4th solver of c1 in that test, so this validates the
-new sentinel.) u-b and u-c stay as `2` and `3` respectively. The
-matrix cell-shape test for the controller
-(`tests/backend/scoreboard-controller.spec.ts`) does not assert on
-the >3 sentinel and needs no change.
-
-### 5.2 `tests/frontend/scoreboard.pure.spec.ts`
-Update `mutateMatrixFromSolve` test:
-```ts
-const next = mutateMatrixFromSolve(matrix, {
- ...
- position: 5,
-});
-expect(next!.cells.p2.c1).toBe('solved');
-```
-(Was `position: 2` expecting `2`; now uses `position: 5` to prove the
-new branch fires.)
-
-## 6. Files touched
-
-- `backend/src/modules/challenges/dto/scoreboard.dto.ts` (type only)
-- `backend/src/modules/challenges/scoreboard.service.ts` (one block, ~3 lines)
-- `frontend/src/app/features/scoreboard/scoreboard.pure.ts` (type + `mutateMatrixFromSolve`)
-- `frontend/src/app/features/scoreboard/matrix.component.ts` (template + remove `cellSolved` helper)
-- `tests/backend/scoreboard.service.spec.ts` (one assertion)
-- `tests/frontend/scoreboard.pure.spec.ts` (one assertion)
-
-## 7. Implementation order
-
-1. Widen `MatrixCellRank` in backend DTO and frontend pure types.
-2. Update backend `getMatrix` to store `'solved'` for positions > 3.
-3. Update `mutateMatrixFromSolve` to write `'solved'` for live 4+ solves.
-4. Update `matrix.component.ts` template (add `'solved'` case, drop default helper branch) and delete `cellSolved()`.
-5. Update the two test assertions.
-6. Run `npm test` (all 590+ tests must pass) and `npm run build`.
-
-## 8. Compatibility & risk
-
-- Public API contract: `cells` JSON gains a new string literal value
- `'solved'`. Older clients that already only branch on `1|2|3|null`
- would render an unknown cell as empty (because of `ngSwitchDefault`
- falling back). To keep current behaviour while migrating, the
- template change MUST land in the same commit as the backend change
- (single-tenant SPA, no external consumers — safe).
-- No DB or migration change; the sentinel is purely a projection
- change over the existing `solve` table.
-- No impact on Ranking, Event Log, or Score Graph — they do not use
- `MatrixCellRank`.
\ No newline at end of file
diff --git a/.kilo/plans/910.md b/.kilo/plans/910.md
new file mode 100644
index 0000000..943b4a1
--- /dev/null
+++ b/.kilo/plans/910.md
@@ -0,0 +1,39 @@
+# 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/backend/src/modules/challenges/challenges.service.ts b/backend/src/modules/challenges/challenges.service.ts
index df75cbd..d2fa1da 100644
--- a/backend/src/modules/challenges/challenges.service.ts
+++ b/backend/src/modules/challenges/challenges.service.ts
@@ -373,6 +373,8 @@ export class ChallengesService {
this.publishSolveEvent({
challengeId,
+ challengeName: challenge.name,
+ categoryAbbreviation: challenge.abbreviation ?? '',
userId: currentUserId,
username: solvers.find((s) => s.userId === currentUserId)?.username ?? '',
awardedPoints,
@@ -405,6 +407,8 @@ export class ChallengesService {
private publishSolveEvent(input: {
challengeId: string;
+ challengeName: string;
+ categoryAbbreviation: string;
userId: string;
username: string;
awardedPoints: number;
@@ -422,6 +426,8 @@ export class ChallengesService {
this.hub.emitScoreboard({
topic: 'solve',
challengeId: input.challengeId,
+ challengeName: input.challengeName,
+ categoryAbbreviation: input.categoryAbbreviation,
userId: input.userId,
playerId: input.userId,
playerName: input.username,
diff --git a/backend/src/modules/challenges/dto/challenges.dto.ts b/backend/src/modules/challenges/dto/challenges.dto.ts
index 100e088..83263a2 100644
--- a/backend/src/modules/challenges/dto/challenges.dto.ts
+++ b/backend/src/modules/challenges/dto/challenges.dto.ts
@@ -81,6 +81,8 @@ export interface ChallengeDetailDto {
export interface SolveEventPayload {
challengeId: string;
+ challengeName: string;
+ categoryAbbreviation: string;
playerId: string;
playerName: string;
awardedPoints: number;
diff --git a/backend/src/modules/challenges/events.controller.ts b/backend/src/modules/challenges/events.controller.ts
index 7ab1875..43429fa 100644
--- a/backend/src/modules/challenges/events.controller.ts
+++ b/backend/src/modules/challenges/events.controller.ts
@@ -28,8 +28,9 @@ export class ChallengesEventsController {
* Emits NestJS MessageEvent objects with the proper `type` field so the
* wire format is `event: \ndata: \n\n`:
* - event: status, payload = { state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end }
- * - event: solve, payload = { challenge_id, player_id, player_name, awarded_points, rank_bonus, awarded_at_utc,
- * position, live_points, solve_count, initial_points, minimum_points, decay_solves }
+ * - event: solve, payload = { challenge_id, challenge_name, category_abbreviation, player_id, player_name,
+ * awarded_points, rank_bonus, awarded_at_utc, position, live_points, solve_count,
+ * initial_points, minimum_points, decay_solves }
* Source of truth: every transition of the event window is broadcast by
* the admin general service through SseHubService.event$(). Solves are
* published by ChallengesService through SseHubService.scoreboard$().
@@ -78,6 +79,8 @@ export class ChallengesEventsController {
type: 'solve',
data: {
challenge_id: s?.challengeId,
+ challenge_name: s?.challengeName ?? '',
+ category_abbreviation: s?.categoryAbbreviation ?? '',
player_id: s?.playerId ?? s?.userId,
player_name: s?.playerName ?? '',
awarded_points: s?.awardedPoints ?? s?.pointsAwarded,
diff --git a/frontend/src/app/features/scoreboard/scoreboard.pure.ts b/frontend/src/app/features/scoreboard/scoreboard.pure.ts
index cd110fb..96e64c4 100644
--- a/frontend/src/app/features/scoreboard/scoreboard.pure.ts
+++ b/frontend/src/app/features/scoreboard/scoreboard.pure.ts
@@ -60,6 +60,8 @@ export interface GraphView {
export interface SolveLivePayload {
challengeId: string;
+ challengeName: string;
+ categoryAbbreviation: string;
playerId: string;
playerName: string;
awardedPoints: number;
diff --git a/frontend/src/app/features/scoreboard/scoreboard.store.ts b/frontend/src/app/features/scoreboard/scoreboard.store.ts
index ad0bbcf..1456f92 100644
--- a/frontend/src/app/features/scoreboard/scoreboard.store.ts
+++ b/frontend/src/app/features/scoreboard/scoreboard.store.ts
@@ -95,6 +95,9 @@ export class ScoreboardStore {
if (!raw) return;
const payload: SolveLivePayload = {
challengeId: raw?.challenge_id ?? raw?.challengeId ?? '',
+ challengeName: raw?.challenge_name ?? raw?.challengeName ?? '',
+ categoryAbbreviation:
+ raw?.category_abbreviation ?? raw?.categoryAbbreviation ?? '',
playerId: raw?.player_id ?? raw?.playerId ?? raw?.userId ?? '',
playerName: raw?.player_name ?? raw?.playerName ?? '',
awardedPoints: Number(raw?.awarded_points ?? raw?.awardedPoints ?? raw?.pointsAwarded ?? 0),
@@ -120,8 +123,8 @@ export class ScoreboardStore {
playerId: payload.playerId,
playerName: payload.playerName,
challengeId: payload.challengeId,
- challengeName: '',
- categoryAbbreviation: '',
+ challengeName: payload.challengeName,
+ categoryAbbreviation: payload.categoryAbbreviation,
position: payload.position,
awardedPoints: payload.awardedPoints,
awardedAtUtc: payload.awardedAtUtc,
diff --git a/tests/backend/challenges-submit-flag.spec.ts b/tests/backend/challenges-submit-flag.spec.ts
index 688aea7..c39e128 100644
--- a/tests/backend/challenges-submit-flag.spec.ts
+++ b/tests/backend/challenges-submit-flag.spec.ts
@@ -364,6 +364,8 @@ describe('POST /api/v1/challenges/:id/solves', () => {
expect(latest).toMatchObject({
topic: 'solve',
challengeId: c.id,
+ challengeName: 'sse-emit',
+ categoryAbbreviation: 'CRY',
playerId: playerId,
position: 1,
awardedPoints: 400 + 15,
diff --git a/tests/frontend/scoreboard.pure.spec.ts b/tests/frontend/scoreboard.pure.spec.ts
index 73b6ec6..d78deff 100644
--- a/tests/frontend/scoreboard.pure.spec.ts
+++ b/tests/frontend/scoreboard.pure.spec.ts
@@ -46,6 +46,8 @@ describe('parseSolveEventIntoRanking', () => {
];
const next = parseSolveEventIntoRanking(rows, {
challengeId: 'c1',
+ challengeName: 'alpha',
+ categoryAbbreviation: 'CRY',
playerId: 'p2',
playerName: 'p2',
awardedPoints: 50,
@@ -90,7 +92,7 @@ describe('applySolveToGraph', () => {
};
const next = applySolveToGraph(
baseView,
- { challengeId: 'c1', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 2 },
+ { challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 2 },
'2025-01-01T00:00:00.000Z',
'2025-01-02T00:00:00.000Z',
'2025-01-01T12:00:00.000Z',
@@ -105,7 +107,7 @@ describe('applySolveToGraph', () => {
it('returns empty when event is not configured', () => {
const next = applySolveToGraph(
null,
- { challengeId: 'c1', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 1 },
+ { challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 1 },
null,
null,
'2025-01-01T12:00:00.000Z',
@@ -123,6 +125,8 @@ describe('mutateMatrixFromSolve', () => {
};
const next = mutateMatrixFromSolve(matrix, {
challengeId: 'c1',
+ challengeName: 'alpha',
+ categoryAbbreviation: 'CRY',
playerId: 'p2',
playerName: 'p2',
awardedPoints: 50,
diff --git a/tests/frontend/scoreboard.store.spec.ts b/tests/frontend/scoreboard.store.spec.ts
index 7ab125b..4a74a0c 100644
--- a/tests/frontend/scoreboard.store.spec.ts
+++ b/tests/frontend/scoreboard.store.spec.ts
@@ -133,6 +133,41 @@ describe('ScoreboardStore', () => {
});
});
+ it('propagates challenge name and category abbreviation from SSE solve frame to the Event Log row', () => {
+ const api = new FakeApi();
+ const destroyRef: any = { onDestroy: () => {} };
+ const store = new ScoreboardStore(api as any, destroyRef);
+ void store.loadAll();
+ return new Promise((resolve) => {
+ const source = makeFakeSse((type, fn) => {
+ if (type === 'solve') {
+ setTimeout(() => {
+ fn(new MessageEvent('solve', { data: JSON.stringify({
+ challenge_id: 'c2',
+ challenge_name: 'Hardware Hello',
+ category_abbreviation: 'HW',
+ player_id: 'p3',
+ player_name: 'PlayerC',
+ awarded_points: 115,
+ rank_bonus: 15,
+ awarded_at_utc: '2026-07-23T05:25:37.309Z',
+ position: 1,
+ }) }));
+ setTimeout(() => {
+ const top = store.eventLog()?.[0];
+ expect(top?.challengeName).toBe('Hardware Hello');
+ expect(top?.categoryAbbreviation).toBe('HW');
+ expect(top?.playerName).toBe('PlayerC');
+ expect(top?.awardedPoints).toBe(115);
+ resolve();
+ }, 0);
+ }, 0);
+ }
+ });
+ store.wireSse(() => source);
+ });
+ });
+
it('reset clears all signals and stops SSE', () => {
const api = new FakeApi();
const destroyRef: any = { onDestroy: () => {} };