AI Implementation feature(865): Scoreboard: Ranking, Matrix, Event Log and Score Graph (#52)

This commit was merged in pull request #52.
This commit is contained in:
2026-07-23 05:13:23 +00:00
parent 49d0930780
commit 7c4843d5cc
26 changed files with 3074 additions and 60 deletions
+162
View File
@@ -0,0 +1,162 @@
# 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
<ng-container [ngSwitch]="view.cells[p.playerId]?.[ch.id]">
<span *ngSwitchCase="1" class="cell-gold" title="1st solver"></span>
<span *ngSwitchCase="2" class="cell-silver" title="2nd solver"></span>
<span *ngSwitchCase="3" class="cell-bronze" title="3rd solver"></span>
<span *ngSwitchCase="'solved'" class="cell-check" title="solved"></span>
<span *ngSwitchDefault></span>
</ng-container>
```
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 `<ng-template #blank>`). 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`.
-26
View File
@@ -1,26 +0,0 @@
# Implementation Plan: Challenges Page and Challenge Solve Modal 1.05
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** TypeScript monorepo with a NestJS backend and Angular 17 standalone frontend. The challenges feature uses OnPush presentational components with inline templates/styles, a root-provided signal store, RxJS HTTP services, and authenticated SSE updates. The backend already returns authoritative per-user `solvedByMe` values, and the store already preserves that isolation across load, submit, SSE, logout, and user-switch boundaries. The remaining defect is accessibility semantics: the visible `✓` is nested inside a click-only `<div>` card and does not reliably appear as an independently exposed solved state in the card's accessibility tree.
- **Data Layer:** SQLite through TypeORM. No schema or migration change is required; `solve` rows and the board API already compute player-specific solve state correctly.
- **Test Framework & Structure:** Root Jest 29 configuration with dedicated suites under `tests/frontend` and `tests/backend`; `npm test` runs all projects and `npm run test:frontend` runs frontend tests. Add one focused, CLI-only source-contract regression test under `tests/frontend` rather than a browser or visual test. The test should verify both solved and unsolved accessibility bindings are derived directly from `card.solvedByMe` and that the solved glyph remains conditionally rendered.
- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, persistent `/data` assets, or `setup.sh` changes are needed. Existing Angular, TypeScript, Jest, ts-jest, and jsdom dependencies are sufficient.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/challenges/challenge-card.component.ts` — make each card a semantic keyboard-operable control and expose its player-specific solved status on the card accessibility node while retaining the visible conditional checkmark and solved border.
- **To Create:**
- `tests/frontend/challenge-card-accessibility.spec.ts` — minimal regression coverage for the card's accessible solved/unsolved state and conditional checkmark wiring.
## 3. Proposed Changes
1. **Database / Schema Migration:** No change. Preserve the existing `solve` uniqueness rules and backend `solvedByMe` calculation; the reported scoring, concurrency, and cross-player API behavior already pass.
2. **Backend Logic & APIs:** No change. Continue consuming `BoardCard.solvedByMe` from `GET /api/v1/challenges/board`, challenge detail, submit responses, and player-matched SSE updates as the sole source of player-specific state.
3. **Frontend UI Integration:**
1. Update the challenge card root in `ChallengeCardComponent` from a generic click target to a native `<button type="button">`, preserving the existing `data-testid`, `(click)` output, solved CSS class, layout, and visual styling. Normalize button typography/alignment/background as needed so the UI does not regress while gaining native keyboard activation and button semantics.
2. Bind an accessible name or state description on the root card that includes the challenge name and an explicit `Solved` versus `Not solved` phrase derived from `card.solvedByMe`. This puts the state on the card node inspected by accessibility-tree tooling instead of relying on a nested decorative glyph.
3. Keep the checkmark conditionally rendered only when `card.solvedByMe` is true, add a stable `data-testid` tied to the challenge id, and mark the glyph `aria-hidden="true"` because the root card now announces the solved state and duplicate speech should be avoided.
4. Do not infer solved state from solve count, points, global solver data, or another player's SSE event. The template must bind only to `card.solvedByMe`, preserving PlayerA/PlayerB isolation already enforced by `ChallengesStore`.
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/challenge-card-accessibility.spec.ts`.
- **Mocking Strategy:** No HTTP, backend, database, SSE, filesystem, or UI-browser mocks. Read the component source as an isolated contract test, following the repository's existing lightweight pattern in `tests/frontend/shell-led.spec.ts`. Assert that the card is a native button, its accessible label/state distinguishes solved from unsolved using `card.solvedByMe`, and the visible checkmark remains conditionally tied to the same field while being hidden from assistive technology to prevent duplicate announcements. Run from the repository root with `npm run test:frontend` (and the full `npm test` verification command during implementation); also run the existing frontend production build because no dedicated lint or typecheck scripts are defined.
@@ -5,16 +5,20 @@ import { CategoryEntity } from '../../database/entities/category.entity';
import { SolveEntity } from '../../database/entities/solve.entity';
import { UserEntity } from '../../database/entities/user.entity';
import { CommonModule } from '../../common/common.module';
import { SettingsModule } from '../settings/settings.module';
import { ChallengesController } from './challenges.controller';
import { ChallengesEventsController } from './events.controller';
import { ChallengesService } from './challenges.service';
import { ScoreboardController } from './scoreboard.controller';
import { ScoreboardService } from './scoreboard.service';
@Module({
imports: [
TypeOrmModule.forFeature([ChallengeEntity, CategoryEntity, SolveEntity, UserEntity]),
CommonModule,
SettingsModule,
],
controllers: [ChallengesController, ChallengesEventsController],
providers: [ChallengesService],
controllers: [ChallengesController, ChallengesEventsController, ScoreboardController],
providers: [ChallengesService, ScoreboardService],
})
export class ChallengesModule {}
@@ -0,0 +1,62 @@
import { z } from 'zod';
export const EventLogQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
});
export type EventLogQuery = z.infer<typeof EventLogQuerySchema>;
export interface RankingRowDto {
rank: number;
playerId: string;
playerName: string;
solvedCount: number;
points: number;
colorIndex: number;
}
export interface MatrixChallengeHeaderDto {
id: string;
name: string;
solveCount: number;
categoryAbbreviation: string;
}
export type MatrixCellRank = 1 | 2 | 3 | 'solved' | null;
export interface MatrixViewDto {
players: RankingRowDto[];
challenges: MatrixChallengeHeaderDto[];
cells: Record<string, Record<string, MatrixCellRank>>;
}
export interface EventLogRowDto {
solveId: string;
playerId: string;
playerName: string;
challengeId: string;
challengeName: string;
categoryAbbreviation: string;
position: number;
awardedPoints: number;
awardedAtUtc: string;
}
export interface GraphPointDto {
tUtc: string;
value: number;
}
export interface GraphSeriesDto {
playerId: string;
playerName: string;
colorIndex: number;
points: GraphPointDto[];
}
export interface GraphViewDto {
startUtc: string | null;
endUtc: string | null;
serverNowUtc: string;
state: 'running' | 'countdown' | 'stopped' | 'unconfigured';
series: GraphSeriesDto[];
}
@@ -0,0 +1,48 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { DataSource } from 'typeorm';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { ScoreboardService } from './scoreboard.service';
import {
EventLogQuerySchema,
EventLogRowDto,
GraphViewDto,
MatrixViewDto,
RankingRowDto,
} from './dto/scoreboard.dto';
@ApiTags('scoreboard')
@ApiBearerAuth()
@Controller('api/v1/scoreboard')
export class ScoreboardController {
constructor(
private readonly dataSource: DataSource,
private readonly svc: ScoreboardService,
) {}
@Get('ranking')
@ApiOperation({ summary: 'Authenticated scoreboard ranking (competition ranks)' })
async ranking(): Promise<RankingRowDto[]> {
return this.svc.getRanking(this.dataSource.manager);
}
@Get('matrix')
@ApiOperation({ summary: 'Authenticated scoreboard matrix (players x challenges)' })
async matrix(): Promise<MatrixViewDto> {
return this.svc.getMatrix(this.dataSource.manager);
}
@Get('event-log')
@ApiOperation({ summary: 'Authenticated recent-solves event log' })
async eventLog(
@Query(new ZodValidationPipe(EventLogQuerySchema)) q: { limit?: number },
): Promise<EventLogRowDto[]> {
return this.svc.getEventLog(this.dataSource.manager, q.limit ?? 50);
}
@Get('graph')
@ApiOperation({ summary: 'Authenticated score graph (top 10 players)' })
async graph(): Promise<GraphViewDto> {
return this.svc.getGraph(this.dataSource.manager);
}
}
@@ -0,0 +1,402 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { SolveEntity } from '../../database/entities/solve.entity';
import { ChallengeEntity } from '../../database/entities/challenge.entity';
import { CategoryEntity } from '../../database/entities/category.entity';
import { UserEntity } from '../../database/entities/user.entity';
import { SettingsService } from '../settings/settings.module';
import { EventStatusService } from '../../common/services/event-status.service';
import {
computeAwardedPoints,
stablePlayerColorIndex,
} from './scoring.util';
import {
EventLogRowDto,
GraphPointDto,
GraphSeriesDto,
GraphViewDto,
MatrixCellRank,
MatrixChallengeHeaderDto,
MatrixViewDto,
RankingRowDto,
} from './dto/scoreboard.dto';
interface RawSolveRow {
id: string;
challengeId: string;
userId: string;
solvedAt: string;
position?: number;
basePoints?: number;
rankBonus?: number;
pointsAwarded?: number;
username?: string;
challengeName?: string;
categoryAbbreviation?: string;
}
@Injectable()
export class ScoreboardService {
constructor(
private readonly settings: SettingsService,
private readonly eventStatus: EventStatusService,
) {}
async getRanking(manager: EntityManager): Promise<RankingRowDto[]> {
const [rows, users] = await Promise.all([
this.loadSolveRows(manager),
manager
.getRepository(UserEntity)
.createQueryBuilder('u')
.select('u.id', 'id')
.addSelect('u.username', 'username')
.getRawMany<{ id: string; username: string }>(),
]);
return this.buildRanking(rows, users);
}
async getMatrix(manager: EntityManager): Promise<MatrixViewDto> {
const [solveRows, challengeRows, users] = await Promise.all([
this.loadSolveRows(manager),
this.loadChallengeRows(manager),
manager
.getRepository(UserEntity)
.createQueryBuilder('u')
.select('u.id', 'id')
.addSelect('u.username', 'username')
.getRawMany<{ id: string; username: string }>(),
]);
const ranking = this.buildRanking(solveRows, users);
const playerById = new Map(ranking.map((r) => [r.playerId, r]));
const challengeSolveCounts = new Map<string, number>();
const positionByPlayerChallenge = new Map<string, Map<string, MatrixCellRank>>();
const grouped = new Map<string, RawSolveRow[]>();
for (const row of solveRows) {
const arr = grouped.get(row.challengeId) ?? [];
arr.push(row);
grouped.set(row.challengeId, arr);
}
for (const [, list] of grouped) {
list.sort((a, b) => new Date(a.solvedAt).getTime() - new Date(b.solvedAt).getTime());
}
for (const [challengeId, list] of grouped) {
challengeSolveCounts.set(challengeId, list.length);
for (let i = 0; i < list.length; i += 1) {
const position = i + 1;
const row = list[i];
let perPlayer = positionByPlayerChallenge.get(row.userId);
if (!perPlayer) {
perPlayer = new Map();
positionByPlayerChallenge.set(row.userId, perPlayer);
}
const value: MatrixCellRank = position <= 3 ? (position as 1 | 2 | 3) : 'solved';
perPlayer.set(challengeId, value);
}
}
const challenges: MatrixChallengeHeaderDto[] = challengeRows
.map((c) => ({
id: c.id,
name: c.name,
solveCount: challengeSolveCounts.get(c.id) ?? 0,
categoryAbbreviation: c.categoryAbbreviation ?? '',
}))
.sort((a, b) => {
if (a.solveCount !== b.solveCount) return b.solveCount - a.solveCount;
const an = (a.name ?? '').toLowerCase();
const bn = (b.name ?? '').toLowerCase();
if (an < bn) return -1;
if (an > bn) return 1;
return 0;
});
const cells: Record<string, Record<string, MatrixCellRank>> = {};
for (const player of ranking) {
const perPlayer = positionByPlayerChallenge.get(player.playerId);
const row: Record<string, MatrixCellRank> = {};
for (const ch of challenges) {
row[ch.id] = perPlayer?.get(ch.id) ?? null;
}
cells[player.playerId] = row;
}
const players = ranking.map((r) => {
const present = playerById.get(r.playerId);
return present ?? r;
});
return { players, challenges, cells };
}
async getEventLog(manager: EntityManager, limit: number): Promise<EventLogRowDto[]> {
const safeLimit = Math.max(1, Math.min(200, Math.floor(limit)));
const rows = await manager
.getRepository(SolveEntity)
.createQueryBuilder('s')
.leftJoin(UserEntity, 'u', 'u.id = s.user_id')
.leftJoin(ChallengeEntity, 'c', 'c.id = s.challenge_id')
.leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id')
.select('s.id', 'id')
.addSelect('s.challenge_id', 'challengeId')
.addSelect('s.user_id', 'userId')
.addSelect('s.solved_at', 'solvedAt')
.addSelect('s.points_awarded', 'pointsAwarded')
.addSelect('s.base_points', 'basePoints')
.addSelect('s.rank_bonus', 'rankBonus')
.addSelect('u.username', 'username')
.addSelect('c.name', 'challengeName')
.addSelect('cat.abbreviation', 'categoryAbbreviation')
.orderBy('s.solved_at', 'DESC')
.addOrderBy('s.id', 'DESC')
.limit(safeLimit)
.getRawMany<RawSolveRow>();
const perChallengeLatestByChallenge = new Map<string, RawSolveRow[]>();
for (const r of rows) {
const arr = perChallengeLatestByChallenge.get(r.challengeId) ?? [];
arr.push(r);
perChallengeLatestByChallenge.set(r.challengeId, arr);
}
for (const arr of perChallengeLatestByChallenge.values()) {
arr.sort((a, b) => new Date(a.solvedAt).getTime() - new Date(b.solvedAt).getTime());
}
const positionBySolveId = new Map<string, number>();
for (const arr of perChallengeLatestByChallenge.values()) {
arr.forEach((row, idx) => positionBySolveId.set(row.id, idx + 1));
}
return rows.map((r) => {
const position = positionBySolveId.get(r.id) ?? 0;
return {
solveId: r.id,
playerId: r.userId,
playerName: r.username ?? '',
challengeId: r.challengeId,
challengeName: r.challengeName ?? '',
categoryAbbreviation: r.categoryAbbreviation ?? '',
position,
awardedPoints: Number(r.pointsAwarded ?? 0),
awardedAtUtc: r.solvedAt,
};
});
}
async getGraph(manager: EntityManager): Promise<GraphViewDto> {
const state = await this.eventStatus.getState();
const startUtc = state.eventStartUtc;
const endUtc = state.eventEndUtc;
const serverNowUtc = state.serverNowUtc;
if (!startUtc || !endUtc) {
return {
startUtc: null,
endUtc: null,
serverNowUtc,
state: state.state,
series: [],
};
}
const challengeRows = await this.loadChallengeRows(manager);
const challengeMeta = new Map(challengeRows.map((c) => [c.id, c]));
const rows = await this.loadSolveRows(manager);
const perPlayer: Map<string, GraphSeriesDto> = new Map();
for (const row of rows) {
const ch = challengeMeta.get(row.challengeId);
if (!ch) continue;
const base = computeAwardedPoints(
ch.initialPoints,
ch.minimumPoints,
ch.decaySolves,
row.solveCountBefore ?? 0,
);
let series = perPlayer.get(row.userId);
if (!series) {
series = {
playerId: row.userId,
playerName: row.username ?? '',
colorIndex: stablePlayerColorIndex(row.userId),
points: [],
};
perPlayer.set(row.userId, series);
}
const last = series.points[series.points.length - 1];
const prevValue = last ? last.value : 0;
series.points.push({ tUtc: row.solvedAt, value: prevValue + base });
}
const all = Array.from(perPlayer.values());
all.sort((a, b) => {
const av = a.points[a.points.length - 1]?.value ?? 0;
const bv = b.points[b.points.length - 1]?.value ?? 0;
if (av !== bv) return bv - av;
const al = a.points[0]?.tUtc ?? '';
const bl = b.points[0]?.tUtc ?? '';
if (al < bl) return -1;
if (al > bl) return 1;
return 0;
});
const top = all.slice(0, 10).map((s) => {
const final = s.points[s.points.length - 1]?.value ?? 0;
const head: GraphPointDto = { tUtc: startUtc, value: 0 };
const tail: GraphPointDto = { tUtc: endUtc, value: final };
return { ...s, points: [head, ...s.points, tail] };
});
return {
startUtc,
endUtc,
serverNowUtc,
state: state.state,
series: top,
};
}
private async loadSolveRows(manager: EntityManager): Promise<RawSolveRowWithMeta[]> {
const all = await manager
.getRepository(SolveEntity)
.createQueryBuilder('s')
.leftJoin(UserEntity, 'u', 'u.id = s.user_id')
.leftJoin(ChallengeEntity, 'c', 'c.id = s.challenge_id')
.select('s.id', 'id')
.addSelect('s.challenge_id', 'challengeId')
.addSelect('s.user_id', 'userId')
.addSelect('s.solved_at', 'solvedAt')
.addSelect('s.points_awarded', 'pointsAwarded')
.addSelect('u.username', 'username')
.addSelect('c.name', 'challengeName')
.orderBy('s.solved_at', 'ASC')
.addOrderBy('s.id', 'ASC')
.getRawMany<RawSolveRow & { challengeName?: string }>();
const perChallenge = new Map<string, RawSolveRowWithMeta[]>();
for (const row of all) {
const arr = perChallenge.get(row.challengeId) ?? [];
arr.push(row);
perChallenge.set(row.challengeId, arr);
}
const out: RawSolveRowWithMeta[] = [];
for (const [, arr] of perChallenge) {
arr.forEach((row, idx) => {
out.push({ ...row, solveCountBefore: idx, position: idx + 1 });
});
}
return out;
}
private async loadChallengeRows(manager: EntityManager): Promise<ChallengeMetaRow[]> {
return manager
.getRepository(ChallengeEntity)
.createQueryBuilder('c')
.leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id')
.select('c.id', 'id')
.addSelect('c.name', 'name')
.addSelect('c.initial_points', 'initialPoints')
.addSelect('c.minimum_points', 'minimumPoints')
.addSelect('c.decay_solves', 'decaySolves')
.addSelect('cat.abbreviation', 'categoryAbbreviation')
.where('c.enabled = 1')
.getRawMany<ChallengeMetaRow>();
}
private buildRanking(
rows: RawSolveRowWithMeta[],
users: { id: string; username: string }[] = [],
): RankingRowDto[] {
interface Accum {
playerId: string;
playerName: string;
points: number;
solved: Set<string>;
earliest: string;
}
const acc = new Map<string, Accum>();
for (const row of rows) {
let entry = acc.get(row.userId);
if (!entry) {
entry = {
playerId: row.userId,
playerName: row.username ?? '',
points: 0,
solved: new Set<string>(),
earliest: row.solvedAt,
};
acc.set(row.userId, entry);
}
if (new Date(row.solvedAt).getTime() < new Date(entry.earliest).getTime()) {
entry.earliest = row.solvedAt;
}
entry.solved.add(row.challengeId);
entry.playerName = entry.playerName || row.username || '';
entry.points += Number(row.pointsAwarded ?? 0);
}
for (const u of users) {
if (!acc.has(u.id)) {
acc.set(u.id, {
playerId: u.id,
playerName: u.username,
points: 0,
solved: new Set<string>(),
earliest: '',
});
}
}
const list: RankingRowDto[] = [];
for (const [, entry] of acc) {
list.push({
rank: 0,
playerId: entry.playerId,
playerName: entry.playerName,
solvedCount: entry.solved.size,
points: entry.points,
colorIndex: stablePlayerColorIndex(entry.playerId),
});
}
list.sort((a, b) => {
if (a.points !== b.points) return b.points - a.points;
const aE = acc.get(a.playerId)?.earliest ?? '';
const bE = acc.get(b.playerId)?.earliest ?? '';
if (aE && bE) {
if (aE < bE) return -1;
if (aE > bE) return 1;
return 0;
}
if (aE) return -1;
if (bE) return 1;
return 0;
});
let displayedRank = 0;
let lastPoints = Number.NaN;
list.forEach((row, idx) => {
if (row.points !== lastPoints) {
displayedRank = idx + 1;
lastPoints = row.points;
}
row.rank = displayedRank;
});
return list;
}
}
interface RawSolveRowWithMeta extends RawSolveRow {
challengeName?: string;
solveCountBefore?: number;
position?: number;
}
interface ChallengeMetaRow {
id: string;
name: string;
initialPoints: number;
minimumPoints: number;
decaySolves: number;
categoryAbbreviation?: string;
}
@@ -39,6 +39,41 @@ export function rankBonusForPosition(position: number): number {
return 0;
}
export function computeAwardedPoints(
initial: number,
minimum: number,
decaySolves: number,
solveCountBefore: number,
): number {
const position = solveCountBefore + 1;
const base = computeLivePoints(initial, minimum, decaySolves, solveCountBefore);
return base + rankBonusForPosition(position);
}
export const PLAYER_COLOR_PALETTE: ReadonlyArray<string> = [
'#f59e0b',
'#3b82f6',
'#10b981',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
'#6366f1',
'#84cc16',
];
export function stablePlayerColorIndex(playerId: string): number {
if (!playerId) return 0;
let hash = 0x811c9dc5;
for (let i = 0; i < playerId.length; i += 1) {
hash ^= playerId.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
const n = PLAYER_COLOR_PALETTE.length;
return ((hash % n) + n) % n;
}
export function safeEqualString(a: string, b: string): boolean {
const ab = Buffer.from(a, 'utf8');
const bb = Buffer.from(b, 'utf8');
+170 -2
View File
@@ -2,8 +2,8 @@
type: api
title: Challenges Endpoints
description: Authenticated challenge board, single-challenge detail, flag submission, event-state snapshot, and the combined authenticated SSE stream that pushes status + solve frames.
tags: [api, challenges, board, score, sse, submit]
timestamp: 2026-07-23T00:10:00Z
tags: [api, challenges, board, score, scoreboard, sse, submit]
timestamp: 2026-07-23T05:10:00Z
---
# Endpoints
@@ -21,6 +21,10 @@ are not `@Public()`). The DTOs and zod schemas live in
| `GET` | `/api/v1/challenges/:id` | Authenticated | Same. |
| `POST` | `/api/v1/challenges/:id/solves` | Authenticated | Same. |
| `SSE` | `/api/v1/events` | Authenticated (SSE) | `backend/src/modules/challenges/events.controller.ts` |
| `GET` | `/api/v1/scoreboard/ranking` | Authenticated | `backend/src/modules/challenges/scoreboard.controller.ts` |
| `GET` | `/api/v1/scoreboard/matrix` | Authenticated | Same. |
| `GET` | `/api/v1/scoreboard/event-log` | Authenticated | Same. |
| `GET` | `/api/v1/scoreboard/graph` | Authenticated | Same. |
> The combined `/api/v1/events` SSE is **in addition to** the existing
> `/api/v1/events/status` status-only stream. The two share the same
@@ -202,6 +206,169 @@ downstream consumer can read either shape (for example
`challenge_id` or `challengeId`, `player_id` or `userId`,
`awarded_points` or `pointsAwarded`).
# Scoreboard endpoints
The `ScoreboardController` mounts the four authenticated read-only
projections that drive the `/scoreboard` page (Ranking, Matrix,
Event Log, Score Graph tabs). All four live under
`/api/v1/scoreboard/*` and are served by
`backend/src/modules/challenges/scoreboard.controller.ts` via
`ScoreboardService`
(`backend/src/modules/challenges/scoreboard.service.ts`). The DTO
contracts live in
`backend/src/modules/challenges/dto/scoreboard.dto.ts` (zod
`EventLogQuerySchema` plus TypeScript interfaces for the response
shapes).
All four endpoints:
* Require a valid JWT (the global `JwtAuthGuard` is active; no
`@Public()` is used on the scoreboard controller).
* Read exclusively — no mutation — and never expose the `challenge.flag`
field or any admin-only data.
* Compute `awardedPoints` per row using
`computeAwardedPoints(initial, minimum, decaySolves, solveCountBefore)`
from `backend/src/modules/challenges/scoring.util.ts`, which is the
same `basePoints + rankBonus` arithmetic that `submitFlag` performs
when persisting the `solve` row.
* Color each player via `stablePlayerColorIndex(playerId)` (FNV-1a
hash → 10-entry `PLAYER_COLOR_PALETTE`) so the Ranking, Matrix, and
Score Graph share a deterministic swatch per `playerId`.
## `GET /api/v1/scoreboard/ranking`
Returns every user (including zero-solve players) ranked by total
points, with competition-rank numbering (`1, 2, 2, 4, …` — players with
equal `points` share a rank).
Response (`RankingRowDto[]`):
```json
[
{
"rank": 1,
"playerId": "<user-uuid>",
"playerName": "alice",
"solvedCount": 7,
"points": 3120,
"colorIndex": 4
}
]
```
`colorIndex` is the same value the frontend uses to tint the
per-player swatch in the Ranking, Matrix, and Score Graph tabs so a
player keeps their color across all three views.
## `GET /api/v1/scoreboard/matrix`
Returns a players × challenges grid for the live scoreboard view.
Response (`MatrixViewDto`):
```json
{
"players": [ /* RankingRowDto[] in ranking order */ ],
"challenges": [
{
"id": "<challenge-uuid>",
"name": "RSA Rollers",
"solveCount": 4,
"categoryAbbreviation": "CRY"
}
],
"cells": {
"<player-uuid>": {
"<challenge-uuid>": 1,
"<challenge-uuid>": 2,
"<challenge-uuid>": 3,
"<challenge-uuid>": "solved",
"<challenge-uuid>": null
}
}
}
```
`cells[playerId][challengeId]` is one of `1` (gold ★, 1st solver),
`2` (silver ★, 2nd solver), `3` (bronze ★, 3rd solver), `'solved'`
(✓, 4th+ solver), or `null` (not solved). Challenges are sorted by
`solveCount` DESC, then by `name` ASC. The service walks the `solve`
rows in `solved_at ASC` order per challenge to assign each cell its
first solver rank.
## `GET /api/v1/scoreboard/event-log`
Recent solves, newest first, joined with the user, challenge, and
category so the frontend never needs a second round-trip to render
labels.
Query string (`EventLogQuerySchema`, validated by `ZodValidationPipe`):
| Param | Type | Default | Description |
|---------|------|---------|----------------------------------------------|
| `limit` | int | `50` | `1 <= limit <= 200`, clamped server-side too. |
Response (`EventLogRowDto[]`):
```json
[
{
"solveId": "<solve-uuid>",
"playerId": "<user-uuid>",
"playerName": "bob",
"challengeId": "<challenge-uuid>",
"challengeName": "Forensic Trail",
"categoryAbbreviation": "FOR",
"position": 1,
"awardedPoints": 515,
"awardedAtUtc": "2026-07-23T05:01:30.000Z"
}
]
```
## `GET /api/v1/scoreboard/graph`
Time-series of cumulative points for the top 10 players over the
configured event window, used to drive the Score Graph tab.
Response (`GraphViewDto`):
```json
{
"startUtc": "2026-07-23T05:00:00.000Z",
"endUtc": "2026-07-23T09:00:00.000Z",
"serverNowUtc": "2026-07-23T05:10:00.000Z",
"state": "running",
"series": [
{
"playerId": "<user-uuid>",
"playerName": "alice",
"colorIndex": 4,
"points": [
{ "tUtc": "2026-07-23T05:00:00.000Z", "value": 0 },
{ "tUtc": "2026-07-23T05:01:30.000Z", "value": 515 },
{ "tUtc": "2026-07-23T05:09:50.000Z", "value": 980 },
{ "tUtc": "2026-07-23T09:00:00.000Z", "value": 980 }
]
}
]
}
```
`state` mirrors the 4-state event window (`running` / `countdown` /
`stopped` / `unconfigured`) and `startUtc` / `endUtc` come from
`SettingsService` via the admin general-settings pipeline. When the
event has not yet started (`countdown`) or has no window configured
(`unconfigured`), the endpoint still returns `200` with empty
`series` and `null` boundaries so the frontend can render the
"Awaiting event configuration" / "Not started" empty states.
Series are sorted by their final cumulative value DESC and trimmed to
the top 10. Each series starts with a `{tUtc: startUtc, value: 0}`
anchor and (when `endUtc` is known) ends with a
`{tUtc: endUtc, value: finalValue}` step so the line plot shows a
flat plateau between the last solve and the event end.
# Errors
The challenges endpoints use three additional error codes from
@@ -221,5 +388,6 @@ The full error envelope and middleware are documented in
- [Challenge Tables](/database/challenges.md)
- [System Endpoints](/api/system.md) (legacy `/events/status` and `/event/stream`)
- [Scoreboard Stream](/guides/scoreboard-stream.md)
- [Scoreboard Page Guide](/guides/scoreboard-page.md)
- [Challenges Board Guide](/guides/challenges-board.md)
- [Backend Module Map](/architecture/backend-modules.md)
+10 -3
View File
@@ -2,8 +2,8 @@
type: architecture
title: Frontend Structure
description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport.
tags: [architecture, frontend, angular, shell, sse, challenges, notifications]
timestamp: 2026-07-23T00:10:00Z
tags: [architecture, frontend, angular, shell, sse, challenges, scoreboard, notifications]
timestamp: 2026-07-23T05:10:00Z
---
# Routes
@@ -16,7 +16,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
| `/login` | `LandingComponent` | `landingGuard` | Public landing and authentication modal. |
| `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. |
| `/challenges` | `ChallengesPage` | inherited | Full challenges board (category columns, modal, flag submit, live SSE). |
| `/scoreboard` | `ScoreboardPage` | inherited | Placeholder scoreboard page. |
| `/scoreboard` | `ScoreboardPage` | inherited | Live scoreboard page with 4 tabs (Ranking / Matrix / Event Log / Score Graph), live SSE `solve` updates. |
| `/blog` | `BlogPage` | inherited | Placeholder blog page. |
| `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. |
| `/admin/challenges` | `AdminChallengesComponent` | inherited | Sortable/searchable admin Challenges list, Add/Edit modal, import/export. |
@@ -37,6 +37,12 @@ Routes live in `frontend/src/app/app.routes.ts`:
| `ChallengesApiService` | `frontend/src/app/features/challenges/challenges.service.ts` | HTTP wrapper around `/api/v1/challenges/{board,status,:id,:id/solves}` returning `Observable<...>` and translating `HttpErrorResponse` into a typed `ApiErrorEnvelope`. |
| `challenges.pure.ts` | `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types + helpers for the challenges feature (`BoardCard`, `SolverRow`, `SolveEventPayload`, sorting, parsers, friendly error mapping, `formatDdHhMm`). |
| `ChallengeCardComponent` / `CategoryColumnComponent` / `ChallengeModalComponent` | `frontend/src/app/features/challenges/` | Presentational components for the board grid, column, and detail modal. |
| `ScoreboardPage` | `frontend/src/app/features/scoreboard/scoreboard.page.ts` | Smart page for `/scoreboard`: holds a `ScoreboardStore`, calls `store.loadAll()` + `store.wireSse(...)` on init, tears them down on destroy, and switches between the 4 tab components via `*ngIf` on `store.activeTab()`. |
| `ScoreboardStore` | `frontend/src/app/features/scoreboard/scoreboard.store.ts` | Root-provided signal store backing `/scoreboard`: holds the four projections, active tab, loading + error state, and the SSE lifecycle (`wireSse` / `stop` / exponential reconnect on transport error). Mutates all four tabs from each `solve` frame via pure helpers (`parseSolveEventIntoRanking`, `mutateMatrixFromSolve`, `applySolveToGraph`, `dedupEventLogBySolveId`). |
| `ScoreboardApiService` | `frontend/src/app/features/scoreboard/scoreboard.service.ts` | HTTP wrapper around `/api/v1/scoreboard/{ranking,matrix,event-log,graph}` returning typed `ApiErrorEnvelope`s. |
| `scoreboard.pure.ts` | `frontend/src/app/features/scoreboard/scoreboard.pure.ts` | Pure types + helpers shared between the store and the four tab components (`RankingRow`, `MatrixView`, `EventLogRow`, `GraphView`, `SolveLivePayload`, `PLAYER_COLOR_PALETTE`, `stablePlayerColorIndex`, `applyRankingSort`, `parseSolveEventIntoRanking`, `dedupEventLogBySolveId`, `applySolveToGraph`, `mutateMatrixFromSolve`, `formatSolveDateTime`). |
| `ScoreboardTabsComponent` | `frontend/src/app/features/scoreboard/tabs.component.ts` | Horizontal tab strip bound to `store.activeTab()`; emits `(select)` to switch tabs. |
| `RankingComponent` / `MatrixComponent` / `EventLogComponent` / `ScoreGraphComponent` | `frontend/src/app/features/scoreboard/` | Presentational components for the 4 tabs; each binds a single `@Input()` from `ScoreboardStore` and uses `PLAYER_COLOR_PALETTE` + `stablePlayerColorIndex` to color each player consistently. |
| `NotificationService` | `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. |
| `errorNotificationInterceptor` | `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Functional HTTP interceptor that pushes a friendly message into `NotificationService` for every `HttpErrorResponse`; suppresses duplicate toasts for endpoints with their own error UI (login form, `/challenges/status` snapshot). |
@@ -121,5 +127,6 @@ for the full contract and tester matrix.
- [Authenticated Shell](/guides/authenticated-shell.md)
- [Event Window](/guides/event-window.md)
- [Challenges Board](/guides/challenges-board.md)
- [Scoreboard Page](/guides/scoreboard-page.md)
- [Notifications](/guides/notifications.md)
- [System Overview](/architecture/overview.md)
+19 -1
View File
@@ -35,8 +35,11 @@ timestamp: 2026-07-23T04:05:00Z
| `backend/src/modules/challenges/challenges.controller.ts` | Registers `/api/v1/challenges/{board,status,:id,:id/solves}` (all JWT-protected). |
| `backend/src/modules/challenges/events.controller.ts` | Authenticated SSE `/api/v1/events` merging status ticks + hub `general` pushes + `scoreboard$` solve frames. |
| `backend/src/modules/challenges/challenges.service.ts` | `getBoard` / `getDetail` / `submitFlag` + `dataSource.transaction` for race-safe idempotent solves; publishes `solve` SSE frames on success. |
| `backend/src/modules/challenges/scoreboard.controller.ts` | Registers the four authenticated scoreboard read-only endpoints under `/api/v1/scoreboard/{ranking,matrix,event-log,graph}`. |
| `backend/src/modules/challenges/scoreboard.service.ts` | Builds the `RankingRowDto[]`, `MatrixViewDto`, `EventLogRowDto[]`, and `GraphViewDto` projections used by the `/scoreboard` page; consumes `SettingsService` for the event window and `EventStatusService` for `state`. |
| `backend/src/modules/challenges/dto/scoreboard.dto.ts` | `EventLogQuerySchema` (zod) + TypeScript DTO interfaces (`RankingRowDto`, `MatrixViewDto`, `EventLogRowDto`, `GraphViewDto`, `GraphSeriesDto`, `GraphPointDto`) for the four scoreboard endpoints. |
| `backend/src/modules/challenges/dto/challenges.dto.ts` | zod contracts (`SolveSubmitBodySchema`, `BoardQuerySchema`, `ChallengeIdParamSchema`) + DTO types for the player-facing board. |
| `backend/src/modules/challenges/scoring.util.ts` | Pure `computeLivePoints`, `rankBonusForPosition`, `compareDifficulty`, and constant-time `safeEqualString` for flag comparison. |
| `backend/src/modules/challenges/scoring.util.ts` | Pure `computeLivePoints`, `rankBonusForPosition`, `compareDifficulty`, `computeAwardedPoints` (base + rank bonus), `PLAYER_COLOR_PALETTE` + `stablePlayerColorIndex` (FNV-1a → palette index), and constant-time `safeEqualString` for flag comparison. |
| `backend/src/common/errors/error-codes.ts` | Canonical error code map (now includes `EVENT_NOT_RUNNING`, `FLAG_INCORRECT`, `CHALLENGE_DISABLED`). |
# Frontend
@@ -79,6 +82,16 @@ timestamp: 2026-07-23T04:05:00Z
| `frontend/src/app/features/challenges/category-column.component.ts` | Renders a single category column (header + cards) on the challenges board. |
| `frontend/src/app/features/challenges/challenge-card.component.ts` | Renders a single challenge card on the board as a semantic `<button>` (difficulty, live points, solve count, `✓` when solved by the player). Exposes `data-testid="challenge-card-<id>"`, `data-solved`, `aria-pressed`, an accessible name (`Challenge <name>, solved|not solved`), and an inner `data-testid="challenge-check-<id>"` for the solved glyph. |
| `frontend/src/app/features/challenges/challenge-modal.component.ts` | Challenge detail modal: description, flag form, awarded banner, solvers list, live-solve updates. |
| `frontend/src/app/features/scoreboard/scoreboard.page.ts` | `/scoreboard` smart page: 4 tabs (Ranking / Matrix / Event Log / Score Graph) wired through `ScoreboardStore`, SSE `solve`-frame subscription on `/api/v1/events`, and lifecycle (`loadAll` + `wireSse` on init, `stop()` on destroy). |
| `frontend/src/app/features/scoreboard/scoreboard.store.ts` | Signal store for the four scoreboard projections + active tab + SSE lifecycle; mutates `ranking`, `matrix`, `event-log`, and `graph` state from each `solve` frame via pure helpers (`parseSolveEventIntoRanking`, `mutateMatrixFromSolve`, `applySolveToGraph`, `dedupEventLogBySolveId`); exposes `wireSse`/`stop`/`reset` and a 1s→30s exponential reconnect loop on transport error. |
| `frontend/src/app/features/scoreboard/scoreboard.service.ts` | HTTP client for `/api/v1/scoreboard/{ranking,matrix,event-log,graph}` returning typed `ApiErrorEnvelope`s. |
| `frontend/src/app/features/scoreboard/scoreboard.pure.ts` | Pure types and helpers: `RankingRow`, `MatrixView`, `EventLogRow`, `GraphView`, `SolveLivePayload`, `PLAYER_COLOR_PALETTE`, `stablePlayerColorIndex`, `applyRankingSort`, `parseSolveEventIntoRanking`, `dedupEventLogBySolveId`, `applySolveToGraph`, `mutateMatrixFromSolve`, `formatSolveDateTime`. |
| `frontend/src/app/features/scoreboard/tabs.component.ts` | Horizontal tab strip (`Ranking` / `Matrix` / `Event Log` / `Score Graph`) bound to `ScoreboardStore.activeTab`. |
| `frontend/src/app/features/scoreboard/ranking.component.ts` | Ranking tab table (rank, player with color swatch, solved count, points). |
| `frontend/src/app/features/scoreboard/matrix.component.ts` | Matrix tab — sticky-header, sticky-player grid of cells (`★` gold/silver/bronze, `✓`, blank) with player color swatches. |
| `frontend/src/app/features/scoreboard/event-log.component.ts` | Event Log tab — newest-first `<ol>` of solves with timestamp, player, challenge, awarded points; position 13 use gold/silver/bronze stars, others use a green check. |
| `frontend/src/app/features/scoreboard/score-graph.component.ts` | Score Graph tab — pure-SVG line chart of top 10 cumulative points over the event window with per-player colored polylines and a legend; renders empty / countdown / unconfigured states. |
| `frontend/src/app/features/scoreboard/scoreboard.page.ts` | `/scoreboard` smart page (also listed above): gates the four tabs, wires `store.loadAll()` and `store.wireSse(...)` on init, and tears them down via `store.stop()` on destroy. |
| `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. |
| `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Translates `HttpErrorResponse` into friendly messages and pushes them to `NotificationService` (with suppression for `/api/v1/auth/{login,csrf,register}` and `/api/v1/challenges/status`). |
| `tests/frontend/challenges.pure.spec.ts` | Pure helpers: sorting, parsers, friendly error mapping, `formatDdHhMm` (`DD:HH:mm:ss`). |
@@ -91,6 +104,10 @@ timestamp: 2026-07-23T04:05:00Z
| `tests/backend/challenges-status-rest.spec.ts` | `/api/v1/challenges/status` snapshot shape. |
| `tests/backend/challenges-events-sse.spec.ts` | `/api/v1/events` SSE: status + solve frames, dedup, ordering. |
| `tests/backend/challenges-submit-flag.spec.ts` | Submit flow: correct/incorrect flag, idempotent re-submit, event-state guard, race handling. |
| `tests/backend/scoreboard-controller.spec.ts` | Contract tests for the four `/api/v1/scoreboard/*` endpoints: JWT protection, ranking sort + competition rank, matrix cell assignment (1/2/3/solved/null), event-log `limit` clamp, graph boundaries + top-10 trim + empty state. |
| `tests/backend/scoreboard.service.spec.ts` | Service-level tests covering `computeAwardedPoints` parity with `submitFlag`, color-index stability, matrix ordering, and graph state handling. |
| `tests/frontend/scoreboard.pure.spec.ts` | Pure helpers: `applyRankingSort` (competition-rank numbering), `parseSolveEventIntoRanking` (add-or-increment + sort), `dedupEventLogBySolveId`, `applySolveToGraph` (anchors + plateau + top-10 trim), `mutateMatrixFromSolve`, `stablePlayerColorIndex`, `formatSolveDateTime`. |
| `tests/frontend/scoreboard.store.spec.ts` | Store lifecycle + live merge: `loadAll` aggregation + idempotency, SSE `solve` frame applying to all four tabs, `stop()` teardown, exponential reconnect. |
# See also
@@ -98,5 +115,6 @@ timestamp: 2026-07-23T04:05:00Z
- [Authenticated Shell](/guides/authenticated-shell.md)
- [System Endpoints](/api/system.md)
- [Challenges Endpoints](/api/challenges.md)
- [Scoreboard Page Guide](/guides/scoreboard-page.md)
- [Challenges Board](/guides/challenges-board.md)
- [Notifications](/guides/notifications.md)
+262
View File
@@ -0,0 +1,262 @@
---
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-<playerId>"` and a
`data-rank="<n>"` 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-<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.
* 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`.
* **Matrix**: the cell at `[<playerId>, <challengeId>]` becomes `1`,
`2`, or `3` if the new `position` is in 13, 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 `<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.
# 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.
+69 -20
View File
@@ -1,37 +1,86 @@
---
type: guide
title: Scoreboard Stream
description: How live solves are broadcast to the SPA over Server-Sent Events.
tags: [guide, scoreboard, sse, stream]
timestamp: 2026-07-21T18:28:00Z
description: How fresh solves are broadcast to clients — both the public unauthenticated scoreboard SSE stream and the authenticated combined /api/v1/events stream that the /scoreboard page consumes.
tags: [guide, scoreboard, sse, stream, public, authenticated]
timestamp: 2026-07-23T05:10:00Z
---
# Endpoint
# Overview
`GET /api/v1/scoreboard/stream` (`@Sse()` handler in
`backend/src/modules/system/system.controller.ts`) opens an SSE stream
that pushes the public scoreboard projection every time a new solve is
recorded.
There are **two** scoreboard-related SSE streams in the codebase:
# Push path
1. The **public** stream `GET /api/v1/scoreboard/stream`, owned by
`SystemController`
(`backend/src/modules/system/system.controller.ts`), is the
`@Public()` `@Sse('scoreboard/stream')` handler that pushes a
flattened solve payload every time the scoreboard hub publishes
a new solve. It is meant for unauthenticated spectators (e.g.
landing-page leaderboard widgets).
2. The **authenticated** combined stream `GET /api/v1/events`
(`backend/src/modules/challenges/events.controller.ts`) merges
`status` ticks, `general` settings pushes, and `solve` frames
into a single JWT-gated transport. The
[Scoreboard Page](/guides/scoreboard-page.md) subscribes here
and reacts to the `solve` event type to mutate all four tabs
in real time.
1. The challenge solve path (admin validation, player submit) updates the
`solve` table.
2. The same code path calls `SseHubService.publish('scoreboard', payload)`
# Public endpoint — `GET /api/v1/scoreboard/stream`
The handler
(`backend/src/modules/system/system.controller.ts`) opens an SSE
stream that pushes a flattened solve payload every time a new solve
is published to the scoreboard hub.
# Push path (both streams)
1. The challenge solve path (admin validation, player submit via
`ChallengesService.submitFlag`) inserts a new `solve` row.
2. The same code path calls
`SseHubService.publish('scoreboard', payload)`
(`backend/src/common/services/sse-hub.service.ts`).
3. Every subscriber to `/api/v1/scoreboard/stream` receives the payload.
3. Subscribers to **either** stream receive the payload:
* `/api/v1/scoreboard/stream` emits a flattened shape
(`challengeId`, `userId`, `pointsAwarded`, `rankBonus`,
`solvedAt`) for anonymous spectators.
* `/api/v1/events` emits a richer `SolveEventPayload` (with both
snake_case and camelCase aliases, plus `position`,
`live_points`, `solve_count`, etc.) for the authenticated SPA.
The hub is in-process; multi-replica deployments need a shared pub/sub
to fan out across pods (not in scope for this repo).
The hub is in-process; multi-replica deployments need a shared
pub/sub to fan out across pods (not in scope for this repo).
# Frontend consumer
# Authenticated consumer — `/scoreboard` page
The authenticated SPA shell subscribes on mount and updates the scoreboard
view whenever a new event arrives. The exact rendering lives inside the
authenticated shell's scoreboard component (see
[Frontend Structure](/architecture/frontend-structure.md)).
The `ScoreboardPage` smart component subscribes to `/api/v1/events`
(not the public stream) on `ngOnInit`:
1. `store.loadAll()` fetches the four projections from
`/api/v1/scoreboard/{ranking,matrix,event-log,graph}` in
parallel and populates the initial state.
2. `store.wireSse(() => authenticatedEventSource.open('/api/v1/events'))`
opens the authenticated SSE transport via
`AuthenticatedEventSourceService` (which adds the Bearer token
and the `'unauthorized'` `401`/`403` channel).
3. The store listens for the `solve` event type and mutates all
four tabs (Ranking, Matrix, Event Log, Score Graph) using pure
helpers in `scoreboard.pure.ts`
(`parseSolveEventIntoRanking`, `mutateMatrixFromSolve`,
`applySolveToGraph`, `dedupEventLogBySolveId`).
4. On transport error the store schedules a reconnect with an
exponential backoff (1s → 30s cap); the `solve` event type
resumes mutation as soon as the stream is back.
5. On `ngOnDestroy`, `store.stop()` aborts the transport and
clears the reconnect timer.
See [Scoreboard Page](/guides/scoreboard-page.md) for the full
tester-facing contract (tabs, test IDs, empty states, error states)
and [Challenges Endpoints](/api/challenges.md) for the request and
response shapes.
# See also
- [Scoreboard Page](/guides/scoreboard-page.md)
- [Event Window](/guides/event-window.md)
- [System Endpoints](/api/system.md)
- [Challenges Endpoints](/api/challenges.md)
+6 -2
View File
@@ -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-23T04:05:00Z.
they need. Last regenerated 2026-07-23T05:10:00Z.
# Architecture
@@ -116,7 +116,11 @@ they need. Last regenerated 2026-07-23T04:05:00Z.
* [Event LED Color Mapping](/guides/event-led-colors.md) - Source of
truth for the shell status dot's color in each `EventState`.
* [Scoreboard Stream](/guides/scoreboard-stream.md) - How live solves are
broadcast to clients.
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.
* [Notifications](/guides/notifications.md) - The root notification
store + global HTTP error interceptor that translate backend error
codes and statuses into friendly user-facing messages.
@@ -0,0 +1,77 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { EventLogRow, formatSolveDateTime } from './scoreboard.pure';
@Component({
selector: 'app-scoreboard-event-log',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
.empty { padding: 32px; text-align: center; opacity: 0.7; }
ol {
list-style: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 6px;
max-height: 70vh; overflow: auto;
}
li {
display: flex; align-items: center; gap: 12px;
padding: 8px 10px;
background: var(--color-surface, #1c1f26);
border: 1px solid var(--color-border, #2a2f3a);
border-radius: var(--radius-sm, 4px);
font-size: 13px;
}
.icon { font-size: 18px; min-width: 22px; text-align: center; }
.icon-gold { color: #d4a017; }
.icon-silver { color: #9aa3b0; }
.icon-bronze { color: #b87333; }
.icon-check { color: var(--color-success, #22c55e); }
.time { opacity: 0.7; font-variant-numeric: tabular-nums; min-width: 170px; }
.who { font-weight: 600; min-width: 120px; }
.what { flex: 1; min-width: 0; }
.abbrev { opacity: 0.65; margin-right: 6px; font-size: 11px; }
.pts { font-weight: 600; font-variant-numeric: tabular-nums; }
`],
template: `
<ng-container *ngIf="rows?.length; else empty">
<ol data-testid="scoreboard-event-log">
<li *ngFor="let r of rows; trackBy: trackBySolve" [attr.data-testid]="'event-log-row-' + r.solveId">
<span [class]="iconClass(r)" class="icon" aria-hidden="true">{{ icon(r) }}</span>
<span class="time">{{ format(r.awardedAtUtc) }}</span>
<span class="who">{{ r.playerName }}</span>
<span class="what">
<span class="abbrev" *ngIf="r.categoryAbbreviation">{{ r.categoryAbbreviation }}</span>
{{ r.challengeName }}
</span>
<span class="pts">+{{ r.awardedPoints }}</span>
</li>
</ol>
</ng-container>
<ng-template #empty>
<p class="empty" data-testid="event-log-empty">No solves yet</p>
</ng-template>
`,
})
export class EventLogComponent {
@Input() rows: EventLogRow[] | null = [];
format = formatSolveDateTime;
icon(r: EventLogRow): string {
if (r.position === 1) return '★';
if (r.position === 2) return '★';
if (r.position === 3) return '★';
return '✓';
}
iconClass(r: EventLogRow): string {
if (r.position === 1) return 'icon-gold';
if (r.position === 2) return 'icon-silver';
if (r.position === 3) return 'icon-bronze';
return 'icon-check';
}
trackBySolve = (_: number, r: EventLogRow) => r.solveId || `${r.playerId}|${r.challengeId}|${r.awardedAtUtc}`;
}
@@ -0,0 +1,114 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PLAYER_COLOR_PALETTE, MatrixView } from './scoreboard.pure';
@Component({
selector: 'app-scoreboard-matrix',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
.empty { padding: 32px; text-align: center; opacity: 0.7; }
.scroll {
overflow: auto;
max-height: 70vh;
max-width: 100%;
border: 1px solid var(--color-border, #2a2f3a);
border-radius: var(--radius-sm, 4px);
background: var(--color-surface, #1c1f26);
}
table {
border-collapse: collapse;
width: max-content;
min-width: 100%;
}
th, td {
padding: 6px 10px;
border-bottom: 1px solid var(--color-border, #2a2f3a);
border-right: 1px solid var(--color-border, #2a2f3a);
font-size: 13px;
text-align: center;
min-width: 56px;
white-space: nowrap;
}
th.sticky-x, td.sticky-x {
position: sticky;
left: 0;
background: var(--color-surface, #1c1f26);
z-index: 1;
text-align: left;
min-width: 160px;
}
thead th {
position: sticky;
top: 0;
background: var(--color-surface, #1c1f26);
z-index: 2;
}
thead th.sticky-x { z-index: 3; }
.abbrev { opacity: 0.65; margin-right: 6px; font-size: 11px; }
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
.cell-gold { color: #d4a017; }
.cell-silver { color: #9aa3b0; }
.cell-bronze { color: #b87333; }
.cell-check { color: var(--color-success, #22c55e); }
`],
template: `
<ng-container *ngIf="view && view.players.length; else empty">
<div class="scroll" data-testid="scoreboard-matrix-scroll">
<table data-testid="scoreboard-matrix">
<thead>
<tr>
<th class="sticky-x" data-testid="matrix-player-header">Player</th>
<th
*ngFor="let ch of view.challenges; trackBy: trackByCh"
[attr.data-testid]="'matrix-col-' + ch.id"
>
<span class="abbrev" *ngIf="ch.categoryAbbreviation">{{ ch.categoryAbbreviation }}</span>
<span>{{ ch.name }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let p of view.players; trackBy: trackByPlayer"
[attr.data-testid]="'matrix-row-' + p.playerId"
>
<td class="sticky-x">
<span class="swatch" [style.background-color]="color(p.colorIndex)" aria-hidden="true"></span>
{{ p.playerName }}
</td>
<td
*ngFor="let ch of view.challenges; trackBy: trackByCh"
[attr.data-testid]="'matrix-cell-' + p.playerId + '-' + ch.id"
>
<ng-container [ngSwitch]="view.cells[p.playerId]?.[ch.id]">
<span *ngSwitchCase="1" class="cell-gold" title="1st solver">★</span>
<span *ngSwitchCase="2" class="cell-silver" title="2nd solver">★</span>
<span *ngSwitchCase="3" class="cell-bronze" title="3rd solver">★</span>
<span *ngSwitchCase="'solved'" class="cell-check" title="solved">✓</span>
<span *ngSwitchDefault></span>
</ng-container>
</td>
</tr>
</tbody>
</table>
</div>
</ng-container>
<ng-template #empty>
<p class="empty" data-testid="matrix-empty">No players yet</p>
</ng-template>
`,
})
export class MatrixComponent {
@Input() view: MatrixView | null = null;
color(idx: number): string {
const safe = ((idx % PLAYER_COLOR_PALETTE.length) + PLAYER_COLOR_PALETTE.length) % PLAYER_COLOR_PALETTE.length;
return PLAYER_COLOR_PALETTE[safe];
}
trackByPlayer = (_: number, p: { playerId: string }) => p.playerId;
trackByCh = (_: number, ch: { id: string }) => ch.id;
}
@@ -0,0 +1,73 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PLAYER_COLOR_PALETTE, RankingRow } from './scoreboard.pure';
@Component({
selector: 'app-scoreboard-ranking',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
.empty { padding: 32px; text-align: center; opacity: 0.7; }
table {
width: 100%;
border-collapse: collapse;
background: var(--color-surface, #1c1f26);
border-radius: var(--radius-sm, 4px);
overflow: hidden;
}
th, td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--color-border, #2a2f3a);
}
th { font-weight: 600; opacity: 0.85; }
.rank { width: 64px; }
.points { text-align: right; font-variant-numeric: tabular-nums; }
.solved { text-align: right; font-variant-numeric: tabular-nums; }
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 8px; vertical-align: middle; }
`],
template: `
<ng-container *ngIf="rows?.length; else empty">
<table data-testid="scoreboard-ranking">
<thead>
<tr>
<th class="rank" aria-sort="none" data-testid="ranking-rank-header">Rank</th>
<th>Player</th>
<th class="solved" data-testid="ranking-solved-header">Solved</th>
<th class="points" data-testid="ranking-points-header">Points</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let row of rows; trackBy: trackById"
[attr.data-testid]="'ranking-row-' + row.playerId"
[attr.data-rank]="row.rank"
>
<td class="rank">{{ row.rank }}</td>
<td>
<span class="swatch" [style.background-color]="color(row.colorIndex)" aria-hidden="true"></span>
{{ row.playerName }}
</td>
<td class="solved">{{ row.solvedCount }}</td>
<td class="points">{{ row.points }}</td>
</tr>
</tbody>
</table>
</ng-container>
<ng-template #empty>
<p class="empty" data-testid="ranking-empty">No players yet</p>
</ng-template>
`,
})
export class RankingComponent {
@Input() rows: RankingRow[] | null = [];
color(idx: number): string {
const safe = ((idx % PLAYER_COLOR_PALETTE.length) + PLAYER_COLOR_PALETTE.length) % PLAYER_COLOR_PALETTE.length;
return PLAYER_COLOR_PALETTE[safe];
}
trackById = (_: number, row: RankingRow) => row.playerId;
}
@@ -0,0 +1,137 @@
import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { GraphView, PLAYER_COLOR_PALETTE } from './scoreboard.pure';
interface Point { x: number; y: number; }
@Component({
selector: 'app-scoreboard-graph',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
.empty, .not-started {
padding: 32px; text-align: center; opacity: 0.7;
}
.legend {
display: flex; flex-wrap: wrap; gap: 8px 12px;
margin-bottom: 8px;
font-size: 12px;
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.swatch { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.frame {
border: 1px solid var(--color-border, #2a2f3a);
border-radius: var(--radius-sm, 4px);
background: var(--color-surface, #1c1f26);
padding: 8px;
}
svg { display: block; width: 100%; height: auto; }
`],
template: `
<ng-container [ngSwitch]="state()">
<ng-container *ngSwitchCase="'unconfigured'">
<p class="empty" data-testid="score-graph-empty">Awaiting event configuration</p>
</ng-container>
<ng-container *ngSwitchCase="'countdown'">
<p class="not-started" data-testid="score-graph-not-started">Not started</p>
</ng-container>
<ng-container *ngSwitchDefault>
<ng-container *ngIf="view && view.series.length; else emptySeries">
<div class="frame" data-testid="score-graph">
<div class="legend">
<span
*ngFor="let s of view.series; trackBy: trackByPlayer"
class="legend-item"
[attr.data-testid]="'score-graph-legend-' + s.playerId"
>
<span class="swatch" [style.background-color]="color(s.colorIndex)" aria-hidden="true"></span>
{{ s.playerName }}
</span>
</div>
<svg
[attr.viewBox]="'0 0 ' + width + ' ' + height"
preserveAspectRatio="xMidYMid meet"
data-testid="score-graph-svg"
>
<g class="axes">
<line [attr.x1]="padL" [attr.y1]="height - padB" [attr.x2]="width - padR" [attr.y2]="height - padB" stroke="currentColor" stroke-opacity="0.3" />
<line [attr.x1]="padL" [attr.y1]="padT" [attr.x2]="padL" [attr.y2]="height - padB" stroke="currentColor" stroke-opacity="0.3" />
<text [attr.x]="padL" [attr.y]="padT - 4" font-size="10" fill="currentColor" fill-opacity="0.6">pts</text>
<text [attr.x]="width - padR" [attr.y]="height - 4" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.6">time</text>
<text [attr.x]="padL - 4" [attr.y]="height - padB" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.6">0</text>
<text [attr.x]="padL - 4" [attr.y]="padT + 4" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.6">{{ maxValue() }}</text>
</g>
<polyline
*ngFor="let s of view.series; trackBy: trackByPlayer"
[attr.points]="pointsAttr(s)"
fill="none"
[attr.stroke]="color(s.colorIndex)"
stroke-width="2"
[attr.data-testid]="'score-graph-line-' + s.playerId"
/>
</svg>
</div>
</ng-container>
<ng-template #emptySeries>
<p class="empty" data-testid="score-graph-empty">No solves yet</p>
</ng-template>
</ng-container>
</ng-container>
`,
})
export class ScoreGraphComponent {
@Input() view: GraphView | null = null;
readonly width = 800;
readonly height = 320;
readonly padL = 40;
readonly padR = 16;
readonly padT = 16;
readonly padB = 28;
readonly state = computed(() => this.view?.state ?? 'unconfigured');
private readonly _maxValue = computed(() => {
const v = this.view;
if (!v) return 0;
let max = 0;
for (const s of v.series) {
for (const p of s.points) {
if (p.value > max) max = p.value;
}
}
return max;
});
maxValue(): number {
return this._maxValue();
}
color(idx: number): string {
const safe = ((idx % PLAYER_COLOR_PALETTE.length) + PLAYER_COLOR_PALETTE.length) % PLAYER_COLOR_PALETTE.length;
return PLAYER_COLOR_PALETTE[safe];
}
pointsAttr(s: { points: { tUtc: string; value: number }[] }): string {
const v = this.view;
if (!v || !v.startUtc || !v.endUtc) return '';
const startMs = new Date(v.startUtc).getTime();
const endMs = new Date(v.endUtc).getTime();
const span = Math.max(1, endMs - startMs);
const maxVal = Math.max(1, this._maxValue());
const innerW = this.width - this.padL - this.padR;
const innerH = this.height - this.padT - this.padB;
return s.points
.map((p) => {
const ms = new Date(p.tUtc).getTime();
const x = this.padL + ((ms - startMs) / span) * innerW;
const y = this.padT + innerH - (p.value / maxVal) * innerH;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(' ');
}
trackByPlayer = (_: number, s: { playerId: string }) => s.playerId;
}
@@ -1,14 +1,94 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthService } from '../../core/services/auth.service';
import { AuthenticatedEventSourceService } from '../../core/services/authenticated-event-source.service';
import { ScoreboardStore } from './scoreboard.store';
import { ScoreboardTabsComponent } from './tabs.component';
import { RankingComponent } from './ranking.component';
import { MatrixComponent } from './matrix.component';
import { EventLogComponent } from './event-log.component';
import { ScoreGraphComponent } from './score-graph.component';
@Component({
selector: 'app-scoreboard-page',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ScoreboardTabsComponent,
RankingComponent,
MatrixComponent,
EventLogComponent,
ScoreGraphComponent,
],
styles: [`
:host { display: block; }
.wrap { padding: 12px 0; }
.loading { opacity: 0.7; padding: 12px; }
.error { color: var(--color-danger, #ef4444); padding: 8px; }
h2 { margin: 0 0 12px; }
`],
template: `
<section class="page-scoreboard">
<section class="wrap" data-testid="scoreboard-page">
<h2>Scoreboard</h2>
<p>Live scoreboard will appear here.</p>
<app-scoreboard-tabs
[active]="store.activeTab()"
(select)="store.setActiveTab($event)"
></app-scoreboard-tabs>
<div *ngIf="store.loading()" class="loading" data-testid="scoreboard-loading">Loading…</div>
<div *ngIf="store.error() as e" class="error" data-testid="scoreboard-error">{{ e }}</div>
<app-scoreboard-ranking
*ngIf="store.activeTab() === 'ranking'"
[rows]="store.ranking()"
></app-scoreboard-ranking>
<app-scoreboard-matrix
*ngIf="store.activeTab() === 'matrix'"
[view]="store.matrix()"
></app-scoreboard-matrix>
<app-scoreboard-event-log
*ngIf="store.activeTab() === 'event-log'"
[rows]="store.eventLog()"
></app-scoreboard-event-log>
<app-scoreboard-graph
*ngIf="store.activeTab() === 'graph'"
[view]="store.graph()"
></app-scoreboard-graph>
</section>
`,
})
export class ScoreboardPage {}
export class ScoreboardPage implements OnInit, OnDestroy {
readonly store = inject(ScoreboardStore);
private readonly auth = inject(AuthService);
private readonly authenticatedEventSource = inject(AuthenticatedEventSourceService);
private readonly destroyRef = inject(DestroyRef);
private sseWired = false;
ngOnInit(): void {
void this.store.loadAll();
if (!this.sseWired) {
this.sseWired = true;
this.store.wireSse(
() => this.authenticatedEventSource.open('/api/v1/events'),
);
}
}
ngOnDestroy(): void {
this.store.stop();
}
}
@@ -0,0 +1,261 @@
export type ScoreboardTab = 'ranking' | 'matrix' | 'event-log' | 'graph';
export const SCOREBOARD_TABS: ScoreboardTab[] = ['ranking', 'matrix', 'event-log', 'graph'];
export interface RankingRow {
rank: number;
playerId: string;
playerName: string;
solvedCount: number;
points: number;
colorIndex: number;
}
export interface MatrixChallengeHeader {
id: string;
name: string;
solveCount: number;
categoryAbbreviation: string;
}
export type MatrixCellRank = 1 | 2 | 3 | 'solved' | null;
export interface MatrixView {
players: RankingRow[];
challenges: MatrixChallengeHeader[];
cells: Record<string, Record<string, MatrixCellRank>>;
}
export interface EventLogRow {
solveId: string;
playerId: string;
playerName: string;
challengeId: string;
challengeName: string;
categoryAbbreviation: string;
position: number;
awardedPoints: number;
awardedAtUtc: string;
}
export interface GraphPoint {
tUtc: string;
value: number;
}
export interface GraphSeries {
playerId: string;
playerName: string;
colorIndex: number;
points: GraphPoint[];
}
export interface GraphView {
startUtc: string | null;
endUtc: string | null;
serverNowUtc: string;
state: 'running' | 'countdown' | 'stopped' | 'unconfigured';
series: GraphSeries[];
}
export interface SolveLivePayload {
challengeId: string;
playerId: string;
playerName: string;
awardedPoints: number;
rankBonus: number;
awardedAtUtc: string;
position: number;
}
export const PLAYER_COLOR_PALETTE: ReadonlyArray<string> = [
'#f59e0b',
'#3b82f6',
'#10b981',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
'#6366f1',
'#84cc16',
];
export function stablePlayerColorIndex(playerId: string): number {
if (!playerId) return 0;
let hash = 0x811c9dc5;
for (let i = 0; i < playerId.length; i += 1) {
hash ^= playerId.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
const n = PLAYER_COLOR_PALETTE.length;
return ((hash % n) + n) % n;
}
export function applyRankingSort(rows: readonly RankingRow[]): RankingRow[] {
const list = rows.map((r) => ({ ...r }));
list.sort((a, b) => {
if (a.points !== b.points) return b.points - a.points;
return 0;
});
let displayedRank = 0;
let lastPoints = Number.NaN;
list.forEach((row, idx) => {
if (row.points !== lastPoints) {
displayedRank = idx + 1;
lastPoints = row.points;
}
row.rank = displayedRank;
});
return list;
}
export function parseSolveEventIntoRanking(
rows: readonly RankingRow[],
payload: SolveLivePayload,
): RankingRow[] {
const out = rows.map((r) => ({ ...r }));
let found = false;
for (const r of out) {
if (r.playerId === payload.playerId) {
r.points += payload.awardedPoints;
r.solvedCount += 1;
found = true;
break;
}
}
if (!found) {
out.push({
rank: 0,
playerId: payload.playerId,
playerName: payload.playerName,
solvedCount: 1,
points: payload.awardedPoints,
colorIndex: stablePlayerColorIndex(payload.playerId),
});
}
return applyRankingSort(out);
}
export function dedupEventLogBySolveId(
existing: readonly EventLogRow[],
newRows: readonly EventLogRow[],
): EventLogRow[] {
if (newRows.length === 0) return existing.slice();
const seen = new Set<string>();
const merged: EventLogRow[] = [];
for (let i = newRows.length - 1; i >= 0; i -= 1) {
const r = newRows[i];
const key = r.solveId || `${r.playerId}|${r.challengeId}|${r.awardedAtUtc}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(r);
}
for (const r of existing) {
const key = r.solveId || `${r.playerId}|${r.challengeId}|${r.awardedAtUtc}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(r);
}
return merged;
}
export function applySolveToGraph(
existing: GraphView | null,
payload: SolveLivePayload,
eventStartUtc: string | null,
eventEndUtc: string | null,
serverNowUtc: string,
): GraphView {
const start = eventStartUtc ?? existing?.startUtc ?? null;
const end = eventEndUtc ?? existing?.endUtc ?? null;
const state: GraphView['state'] = existing?.state ?? 'running';
if (!start || !end) {
return {
startUtc: start,
endUtc: end,
serverNowUtc,
state,
series: existing?.series ?? [],
};
}
const seriesList = (existing?.series ?? []).map((s) => ({ ...s, points: [...s.points] }));
let series = seriesList.find((s) => s.playerId === payload.playerId);
if (!series) {
series = {
playerId: payload.playerId,
playerName: payload.playerName,
colorIndex: stablePlayerColorIndex(payload.playerId),
points: [{ tUtc: start, value: 0 }],
};
seriesList.push(series);
}
const head = series.points[0];
if (!head || head.tUtc !== start) {
series.points = [{ tUtc: start, value: 0 }, ...series.points];
}
const endIdx = series.points.findIndex((p) => p.tUtc === end);
const prevValue = endIdx >= 0
? series.points[endIdx].value
: (series.points[series.points.length - 1]?.value ?? 0);
const newValue = prevValue + payload.awardedPoints;
if (endIdx >= 0) {
const body = series.points.slice(1, endIdx);
series.points = [
{ tUtc: start, value: 0 },
...body,
{ tUtc: payload.awardedAtUtc, value: newValue },
{ tUtc: end, value: newValue },
];
} else {
series.points = [
...series.points,
{ tUtc: payload.awardedAtUtc, value: newValue },
];
}
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);
return { startUtc: start, endUtc: end, serverNowUtc, state, series: top };
}
export function mutateMatrixFromSolve(
matrix: MatrixView | null,
payload: SolveLivePayload,
): MatrixView | null {
if (!matrix) return matrix;
const players = matrix.players.map((p) => ({ ...p }));
let player = players.find((p) => p.playerId === payload.playerId);
if (!player) {
player = {
rank: 0,
playerId: payload.playerId,
playerName: payload.playerName,
solvedCount: 1,
points: payload.awardedPoints,
colorIndex: stablePlayerColorIndex(payload.playerId),
};
players.push(player);
}
const cells = { ...matrix.cells };
const cellRow = { ...(cells[payload.playerId] ?? {}) };
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';
}
cells[payload.playerId] = cellRow;
return { players, challenges: matrix.challenges, cells };
}
export function formatSolveDateTime(utc: string | null | undefined): string {
if (!utc) return '';
const d = new Date(utc);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
@@ -0,0 +1,60 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, catchError, throwError } from 'rxjs';
import {
EventLogRow,
GraphView,
MatrixView,
RankingRow,
} from './scoreboard.pure';
export interface ApiErrorEnvelope {
status: number;
code?: string;
message?: string;
details?: unknown;
}
function toEnvelope(err: unknown): ApiErrorEnvelope {
if (err instanceof HttpErrorResponse) {
return {
status: err.status,
code: err.error?.code,
message: err.error?.message,
details: err.error?.details,
};
}
return { status: 0, code: 'NETWORK', message: (err as Error)?.message };
}
@Injectable({ providedIn: 'root' })
export class ScoreboardApiService {
private readonly http = inject(HttpClient);
getRanking(): Observable<RankingRow[]> {
return this.http
.get<RankingRow[]>('/api/v1/scoreboard/ranking', { withCredentials: true })
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
}
getMatrix(): Observable<MatrixView> {
return this.http
.get<MatrixView>('/api/v1/scoreboard/matrix', { withCredentials: true })
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
}
getEventLog(limit = 50): Observable<EventLogRow[]> {
return this.http
.get<EventLogRow[]>('/api/v1/scoreboard/event-log', {
withCredentials: true,
params: { limit: String(limit) },
})
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
}
getGraph(): Observable<GraphView> {
return this.http
.get<GraphView>('/api/v1/scoreboard/graph', { withCredentials: true })
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
}
}
@@ -0,0 +1,210 @@
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
import { ScoreboardApiService } from './scoreboard.service';
import {
EventLogRow,
GraphView,
MatrixView,
RankingRow,
SCOREBOARD_TABS,
ScoreboardTab,
SolveLivePayload,
applySolveToGraph,
dedupEventLogBySolveId,
mutateMatrixFromSolve,
parseSolveEventIntoRanking,
} from './scoreboard.pure';
import { EventSourceLike } from '../../core/services/event-status.pure';
@Injectable({ providedIn: 'root' })
export class ScoreboardStore {
private readonly api: ScoreboardApiService;
private readonly destroyRef: DestroyRef;
private readonly _ranking = signal<RankingRow[] | null>(null);
private readonly _matrix = signal<MatrixView | null>(null);
private readonly _eventLog = signal<EventLogRow[] | null>(null);
private readonly _graph = signal<GraphView | null>(null);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
private readonly _activeTab = signal<ScoreboardTab>('ranking');
private sse: EventSourceLike | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectDelayMs = 1000;
private createSource: (() => EventSourceLike) | null = null;
private loadAllInFlight: Promise<void> | null = null;
constructor(api?: ScoreboardApiService, destroyRef?: DestroyRef) {
this.api = api ?? inject(ScoreboardApiService);
this.destroyRef = destroyRef ?? inject(DestroyRef);
this.destroyRef.onDestroy(() => this.stop());
}
readonly ranking = this._ranking.asReadonly();
readonly matrix = this._matrix.asReadonly();
readonly eventLog = this._eventLog.asReadonly();
readonly graph = this._graph.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly activeTab = this._activeTab.asReadonly();
readonly tabs = computed<ScoreboardTab[]>(() => [...SCOREBOARD_TABS]);
setActiveTab(tab: ScoreboardTab): void {
this._activeTab.set(tab);
}
async loadAll(): Promise<void> {
if (this.loadAllInFlight) return this.loadAllInFlight;
if (this._ranking() && this._matrix() && this._eventLog() && this._graph()) {
return;
}
this._loading.set(true);
this._error.set(null);
this.loadAllInFlight = (async () => {
try {
const ranking = await this.toPromise<RankingRow[]>(() => this.api.getRanking());
const matrix = await this.toPromise<MatrixView>(() => this.api.getMatrix());
const eventLog = await this.toPromise<EventLogRow[]>(() => this.api.getEventLog(50));
const graph = await this.toPromise<GraphView>(() => this.api.getGraph());
this._ranking.set(ranking);
this._matrix.set(matrix);
this._eventLog.set(eventLog);
this._graph.set(graph);
} catch (err: any) {
this._error.set(err?.message ?? 'Failed to load scoreboard');
} finally {
this._loading.set(false);
this.loadAllInFlight = null;
}
})();
return this.loadAllInFlight;
}
wireSse(createSource: () => EventSourceLike, onUnauthorized?: () => void): void {
this.stop();
this.createSource = createSource;
this.openSource(onUnauthorized);
}
handleSseFrame(ev: MessageEvent | Event): void {
const me = ev as MessageEvent;
if (!me || me.type !== 'solve') return;
try {
const raw = typeof me.data === 'string' ? JSON.parse(me.data) : me.data;
if (!raw) return;
const payload: SolveLivePayload = {
challengeId: raw?.challenge_id ?? raw?.challengeId ?? '',
playerId: raw?.player_id ?? raw?.playerId ?? raw?.userId ?? '',
playerName: raw?.player_name ?? raw?.playerName ?? '',
awardedPoints: Number(raw?.awarded_points ?? raw?.awardedPoints ?? raw?.pointsAwarded ?? 0),
rankBonus: Number(raw?.rank_bonus ?? raw?.rankBonus ?? 0),
awardedAtUtc: raw?.awarded_at_utc ?? raw?.awardedAtUtc ?? raw?.solvedAt ?? '',
position: Number(raw?.position ?? 0),
};
this.applySolveEvent(payload);
} catch {
// ignore malformed frame
}
}
applySolveEvent(payload: SolveLivePayload): void {
const ranking = this._ranking();
if (ranking) {
this._ranking.set(parseSolveEventIntoRanking(ranking, payload));
}
const eventLog = this._eventLog();
if (eventLog) {
const newRow: EventLogRow = {
solveId: '',
playerId: payload.playerId,
playerName: payload.playerName,
challengeId: payload.challengeId,
challengeName: '',
categoryAbbreviation: '',
position: payload.position,
awardedPoints: payload.awardedPoints,
awardedAtUtc: payload.awardedAtUtc,
};
this._eventLog.set(dedupEventLogBySolveId(eventLog, [newRow]).slice(0, 200));
}
const graph = this._graph();
if (graph) {
const next = applySolveToGraph(graph, payload, graph.startUtc, graph.endUtc, graph.serverNowUtc);
this._graph.set(next);
}
const matrix = this._matrix();
if (matrix) {
this._matrix.set(mutateMatrixFromSolve(matrix, payload));
}
}
reset(): void {
this.stop();
this._ranking.set(null);
this._matrix.set(null);
this._eventLog.set(null);
this._graph.set(null);
this._error.set(null);
this._loading.set(false);
this._activeTab.set('ranking');
}
stop(): void {
if (this.sse) {
try {
this.sse.close();
} catch {
// ignore
}
this.sse = null;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.createSource = null;
this.reconnectDelayMs = 1000;
}
private openSource(onUnauthorized?: () => void): void {
if (!this.createSource) return;
const src = this.createSource();
this.sse = src;
src.addEventListener('solve', (ev) => this.handleSseFrame(ev));
src.addEventListener('error', () => {
if (this.sse === src) {
try { src.close(); } catch { /* ignore */ }
this.sse = null;
this.scheduleReconnect(onUnauthorized);
}
});
if (onUnauthorized) {
src.addEventListener('unauthorized', () => {
try {
onUnauthorized();
} catch {
// ignore
}
});
}
}
private scheduleReconnect(onUnauthorized?: () => void): void {
if (!this.createSource) return;
if (this.reconnectTimer) return;
const delay = Math.min(this.reconnectDelayMs, 30_000);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.createSource) return;
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, 30_000);
this.openSource(onUnauthorized);
}, delay);
}
private toPromise<T>(fn: () => any): Promise<T> {
return new Promise<T>((resolve, reject) => {
fn().subscribe({ next: (v: T) => resolve(v), error: reject });
});
}
}
@@ -0,0 +1,63 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SCOREBOARD_TABS, ScoreboardTab } from './scoreboard.pure';
interface TabSpec {
id: ScoreboardTab;
label: string;
}
const TAB_LABELS: Readonly<Record<ScoreboardTab, string>> = {
ranking: 'Ranking',
matrix: 'Matrix',
'event-log': 'Event Log',
graph: 'Score Graph',
};
@Component({
selector: 'app-scoreboard-tabs',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
nav {
display: flex; gap: 6px;
border-bottom: 1px solid var(--color-border, #2a2f3a);
padding-bottom: 6px;
margin-bottom: 12px;
overflow-x: auto;
}
button {
padding: 6px 12px;
border-radius: var(--radius-sm, 4px);
background: var(--color-surface, #1c1f26);
color: inherit;
border: 1px solid var(--color-border, #2a2f3a);
cursor: pointer;
font: inherit;
}
button.active {
background: var(--color-primary, #3b82f6);
color: var(--color-primary-contrast, #fff);
border-color: var(--color-primary, #3b82f6);
}
`],
template: `
<nav data-testid="scoreboard-tabs">
<button
*ngFor="let t of tabs"
type="button"
[class.active]="t.id === active"
[attr.data-testid]="'scoreboard-tab-' + t.id"
(click)="select.emit(t.id)"
>{{ t.label }}</button>
</nav>
`,
})
export class ScoreboardTabsComponent {
@Input({ required: true }) active!: ScoreboardTab;
@Output() select = new EventEmitter<ScoreboardTab>();
readonly tabs: TabSpec[] = SCOREBOARD_TABS.map((id) => ({ id, label: TAB_LABELS[id] }));
}
+218
View File
@@ -0,0 +1,218 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_DIR = '/tmp/hipctf-scoreboard-controller-test';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import * as express from 'express';
import request from 'supertest';
import { getRepositoryToken } from '@nestjs/typeorm';
import { v4 as uuid } from 'uuid';
import * as argon2 from 'argon2';
import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
import { ConfigService } from '@nestjs/config';
import { initDb } from './db-helper';
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
import { SolveEntity } from '../../backend/src/database/entities/solve.entity';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SettingsService } from '../../backend/src/modules/settings/settings.module';
import { SETTINGS_KEYS } from '../../backend/src/config/env.schema';
describe('GET /api/v1/scoreboard/*', () => {
let app: INestApplication;
let adminToken: string;
let solveRepo: any;
let userRepo: any;
let challengeRepo: any;
let categoryRepo: any;
let settings: SettingsService;
let challengeA: ChallengeEntity;
let challengeB: ChallengeEntity;
let playerA: string;
let playerB: string;
async function setRunningWindow(): Promise<void> {
await settings.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 3600_000).toISOString());
await settings.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() + 3600_000).toISOString());
}
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.use(cookieParser());
app.use(express.json({ limit: '1mb' }));
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
const httpAdapterHost = app.get(HttpAdapterHost);
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
await app.init();
await initDb(app);
settings = app.get(SettingsService);
solveRepo = app.get(getRepositoryToken(SolveEntity));
userRepo = app.get(getRepositoryToken(UserEntity));
challengeRepo = app.get(getRepositoryToken(ChallengeEntity));
categoryRepo = app.get(getRepositoryToken(CategoryEntity));
await setRunningWindow();
const server = app.getHttpServer();
await request(server).post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
const csrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
const login = await request(server).post('/api/v1/auth/login')
.set('Cookie', `csrf=${csrf}`)
.set('X-CSRF-Token', csrf)
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
adminToken = login.body.accessToken;
const cry = await categoryRepo.findOne({ where: { systemKey: 'CRY' } });
if (!cry) throw new Error('expected CRY category');
challengeA = await challengeRepo.save(challengeRepo.create({
id: uuid(),
name: 'chal-a',
descriptionMd: '',
categoryId: cry.id,
difficulty: 'LOW',
initialPoints: 500,
minimumPoints: 100,
decaySolves: 2,
flag: 'flag{a}',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
}));
challengeB = await challengeRepo.save(challengeRepo.create({
id: uuid(),
name: 'chal-b',
descriptionMd: '',
categoryId: cry.id,
difficulty: 'LOW',
initialPoints: 200,
minimumPoints: 50,
decaySolves: 5,
flag: 'flag{b}',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
}));
const passwordHash = await argon2.hash('PlayerPass!1Ab', { type: argon2.argon2id });
const a = await userRepo.save(userRepo.create({ id: uuid(), username: 'alice', passwordHash, role: 'player', status: 'enabled' }));
const b = await userRepo.save(userRepo.create({ id: uuid(), username: 'bob', passwordHash, role: 'player', status: 'enabled' }));
const c = await userRepo.save(userRepo.create({ id: uuid(), username: 'carol', passwordHash, role: 'player', status: 'enabled' }));
playerA = a.id;
playerB = b.id;
void c;
await solveRepo.save(solveRepo.create({
id: uuid(),
challengeId: challengeA.id,
userId: playerA,
solvedAt: new Date(Date.now() - 1000).toISOString(),
pointsAwarded: 515,
basePoints: 500,
rankBonus: 15,
isFirst: 1,
isSecond: 0,
isThird: 0,
}));
await solveRepo.save(solveRepo.create({
id: uuid(),
challengeId: challengeB.id,
userId: playerA,
solvedAt: new Date(Date.now() - 500).toISOString(),
pointsAwarded: 210,
basePoints: 200,
rankBonus: 10,
isFirst: 1,
isSecond: 0,
isThird: 0,
}));
await solveRepo.save(solveRepo.create({
id: uuid(),
challengeId: challengeA.id,
userId: playerB,
solvedAt: new Date(Date.now() - 100).toISOString(),
pointsAwarded: 110,
basePoints: 100,
rankBonus: 10,
isFirst: 0,
isSecond: 1,
isThird: 0,
}));
});
afterAll(async () => {
await app.close();
});
function authed(): any {
const r: any = request(app.getHttpServer());
r.set = ((h: string, v: string) => { r.headers = r.headers || {}; r.headers[h] = v; return r; });
return r;
}
it('rejects unauthenticated ranking with 401', async () => {
await request(app.getHttpServer()).get('/api/v1/scoreboard/ranking').expect(401);
});
it('returns competition-ranked ranking including zero-pt player', async () => {
const res = await authed().get('/api/v1/scoreboard/ranking').set('Authorization', `Bearer ${adminToken}`).expect(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body.length).toBe(4);
expect(res.body[0]).toMatchObject({ rank: 1, playerName: 'alice' });
expect(res.body[0].points).toBe(725);
expect(res.body[1].rank).toBe(2);
const zeroPointRows = res.body.filter((r: any) => r.points === 0);
expect(zeroPointRows.length).toBe(2);
expect(zeroPointRows[0].rank).toBe(3);
});
it('returns matrix with players and challenges', async () => {
const res = await authed().get('/api/v1/scoreboard/matrix').set('Authorization', `Bearer ${adminToken}`).expect(200);
expect(res.body.players.length).toBe(4);
expect(res.body.challenges.length).toBeGreaterThanOrEqual(2);
const challengeIds = res.body.challenges.map((c: any) => c.id);
expect(challengeIds).toContain(challengeA.id);
expect(challengeIds).toContain(challengeB.id);
const aliceCells = res.body.cells[playerA];
expect(aliceCells[challengeA.id]).toBe(1);
expect(aliceCells[challengeB.id]).toBe(1);
const bobCells = res.body.cells[playerB];
expect(bobCells[challengeA.id]).toBe(2);
expect(bobCells[challengeB.id]).toBeNull();
});
it('returns event-log in DESC order with limit', async () => {
const res = await authed().get('/api/v1/scoreboard/event-log?limit=2').set('Authorization', `Bearer ${adminToken}`).expect(200);
expect(res.body.length).toBe(2);
const ts = res.body.map((r: any) => r.awardedAtUtc);
expect(ts[0] >= ts[1]).toBe(true);
});
it('returns graph with top-10 series and start/end anchors', async () => {
const res = await authed().get('/api/v1/scoreboard/graph').set('Authorization', `Bearer ${adminToken}`).expect(200);
expect(res.body.state).toBe('running');
expect(res.body.startUtc).toBeTruthy();
expect(res.body.endUtc).toBeTruthy();
expect(res.body.series.length).toBeGreaterThan(0);
const top = res.body.series[0];
expect(top.points[0]).toMatchObject({ value: 0 });
expect(top.points[top.points.length - 1].tUtc).toBe(res.body.endUtc);
});
});
+237
View File
@@ -0,0 +1,237 @@
import { ScoreboardService } from '../../backend/src/modules/challenges/scoreboard.service';
import {
computeAwardedPoints,
PLAYER_COLOR_PALETTE,
stablePlayerColorIndex,
} from '../../backend/src/modules/challenges/scoring.util';
function buildMockManager(opts: {
solves?: any[];
challenges?: any[];
users?: any[];
}): any {
const solves = opts.solves ?? [];
const challenges = opts.challenges ?? [];
const users = opts.users ?? [];
function solveRepo() {
return {
createQueryBuilder: () => {
const b: any = {};
b.leftJoin = jest.fn().mockReturnThis();
b.select = jest.fn().mockReturnThis();
b.addSelect = jest.fn().mockReturnThis();
b.orderBy = jest.fn().mockReturnThis();
b.addOrderBy = jest.fn().mockReturnThis();
b.limit = jest.fn().mockReturnThis();
b.getRawMany = jest.fn().mockImplementation(async () => solves);
return b;
},
};
}
function userRepo() {
return {
createQueryBuilder: () => {
const b: any = {};
b.select = jest.fn().mockReturnThis();
b.addSelect = jest.fn().mockReturnThis();
b.getRawMany = jest.fn().mockImplementation(async () => users);
return b;
},
};
}
function challengeRepo() {
return {
createQueryBuilder: () => {
const b: any = {};
b.leftJoin = jest.fn().mockReturnThis();
b.select = jest.fn().mockReturnThis();
b.addSelect = jest.fn().mockReturnThis();
b.where = jest.fn().mockReturnThis();
b.getRawMany = jest.fn().mockImplementation(async () => challenges);
return b;
},
};
}
return {
getRepository: (entity: any) => {
const name = entity?.name ?? '';
if (name === 'SolveEntity') return solveRepo();
if (name === 'UserEntity') return userRepo();
if (name === 'ChallengeEntity') return challengeRepo();
return { createQueryBuilder: () => ({ getRawMany: async () => [] }) };
},
};
}
const fakeSettings: any = { get: jest.fn().mockResolvedValue('') };
const fakeEventStatus: any = {
getState: jest.fn().mockResolvedValue({
state: 'unconfigured',
serverNowUtc: '2025-01-01T00:00:00.000Z',
eventStartUtc: null,
eventEndUtc: null,
secondsToStart: null,
secondsToEnd: null,
}),
};
describe('scoring.util additions', () => {
it('computeAwardedPoints adds rank bonus for 1st/2nd/3rd only', () => {
expect(computeAwardedPoints(500, 100, 10, 0)).toBe(500 + 15);
expect(computeAwardedPoints(500, 100, 10, 1)).toBe(460 + 10);
expect(computeAwardedPoints(500, 100, 10, 2)).toBe(420 + 5);
expect(computeAwardedPoints(500, 100, 10, 3)).toBe(380);
expect(computeAwardedPoints(500, 100, 10, 9)).toBe(140);
});
it('stablePlayerColorIndex is deterministic and in range', () => {
const idx = stablePlayerColorIndex('player-abc');
expect(idx).toBeGreaterThanOrEqual(0);
expect(idx).toBeLessThan(PLAYER_COLOR_PALETTE.length);
expect(stablePlayerColorIndex('player-abc')).toBe(idx);
expect(stablePlayerColorIndex('player-xyz')).not.toBe(idx);
});
});
describe('ScoreboardService.getRanking', () => {
it('returns competition ranks with zero-pt players sorted to the bottom', async () => {
const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
const mgr = buildMockManager({
solves: [
{ id: 's1', challengeId: 'c1', userId: 'u-alice', solvedAt: '2025-01-01T00:00:00.000Z', pointsAwarded: 100, username: 'alice' },
{ id: 's2', challengeId: 'c2', userId: 'u-alice', solvedAt: '2025-01-01T00:01:00.000Z', pointsAwarded: 50, username: 'alice' },
{ id: 's3', challengeId: 'c1', userId: 'u-bob', solvedAt: '2025-01-01T00:02:00.000Z', pointsAwarded: 80, username: 'bob' },
],
users: [
{ id: 'u-alice', username: 'alice' },
{ id: 'u-bob', username: 'bob' },
{ id: 'u-carol', username: 'carol' },
],
});
const ranking = await svc.getRanking(mgr);
expect(ranking).toHaveLength(3);
expect(ranking[0]).toMatchObject({ rank: 1, playerId: 'u-alice', points: 150, solvedCount: 2 });
expect(ranking[1]).toMatchObject({ rank: 2, playerId: 'u-bob', points: 80, solvedCount: 1 });
expect(ranking[2]).toMatchObject({ rank: 3, playerId: 'u-carol', points: 0, solvedCount: 0 });
for (const r of ranking) {
expect(r.colorIndex).toBeGreaterThanOrEqual(0);
expect(r.colorIndex).toBeLessThan(PLAYER_COLOR_PALETTE.length);
}
});
it('uses earliest solve as tiebreaker', async () => {
const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
const mgr = buildMockManager({
solves: [
{ id: 's1', challengeId: 'c1', userId: 'u-bob', solvedAt: '2025-01-01T00:01:00.000Z', pointsAwarded: 100, username: 'bob' },
{ id: 's2', challengeId: 'c1', userId: 'u-alice', solvedAt: '2025-01-01T00:00:00.000Z', pointsAwarded: 100, username: 'alice' },
],
users: [
{ id: 'u-alice', username: 'alice' },
{ id: 'u-bob', username: 'bob' },
],
});
const ranking = await svc.getRanking(mgr);
expect(ranking[0].playerId).toBe('u-alice');
expect(ranking[1].playerId).toBe('u-bob');
});
});
describe('ScoreboardService.getMatrix', () => {
it('marks top-3 positions and sorts challenges by solve count DESC then name', async () => {
const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
const mgr = buildMockManager({
solves: [
{ id: 's1', challengeId: 'c1', userId: 'u-a', solvedAt: '2025-01-01T00:00:00.000Z', pointsAwarded: 50, username: 'a' },
{ id: 's2', challengeId: 'c1', userId: 'u-b', solvedAt: '2025-01-01T00:01:00.000Z', pointsAwarded: 50, username: 'b' },
{ id: 's3', challengeId: 'c1', userId: 'u-c', solvedAt: '2025-01-01T00:02:00.000Z', pointsAwarded: 50, username: 'c' },
{ id: 's4', challengeId: 'c1', userId: 'u-d', solvedAt: '2025-01-01T00:03:00.000Z', pointsAwarded: 50, username: 'd' },
{ id: 's5', challengeId: 'c2', userId: 'u-a', solvedAt: '2025-01-01T00:04:00.000Z', pointsAwarded: 50, username: 'a' },
],
challenges: [
{ id: 'c1', name: 'alpha', initialPoints: 500, minimumPoints: 100, decaySolves: 10, categoryAbbreviation: 'CRY' },
{ id: 'c2', name: 'beta', initialPoints: 300, minimumPoints: 50, decaySolves: 5, categoryAbbreviation: 'WEB' },
],
users: [
{ id: 'u-a', username: 'a' },
{ id: 'u-b', username: 'b' },
{ id: 'u-c', username: 'c' },
{ id: 'u-d', username: 'd' },
],
});
const matrix = await svc.getMatrix(mgr);
expect(matrix.challenges[0].id).toBe('c1');
expect(matrix.challenges[1].id).toBe('c2');
expect(matrix.cells['u-a']?.['c1']).toBe(1);
expect(matrix.cells['u-b']?.['c1']).toBe(2);
expect(matrix.cells['u-c']?.['c1']).toBe(3);
expect(matrix.cells['u-d']?.['c1']).toBe('solved');
expect(matrix.cells['u-a']?.['c2']).toBe(1);
expect(matrix.cells['u-b']?.['c2']).toBeNull();
});
});
describe('ScoreboardService.getGraph', () => {
it('prepends start point and appends end point, limits to top 10', async () => {
const settings: any = {
get: jest.fn().mockImplementation(async (k: string) => {
if (k === 'eventStartUtc') return '2025-01-01T00:00:00.000Z';
if (k === 'eventEndUtc') return '2025-01-02T00:00:00.000Z';
return '';
}),
};
const eventStatus: any = {
getState: jest.fn().mockResolvedValue({
state: 'running',
serverNowUtc: '2025-01-01T12:00:00.000Z',
eventStartUtc: '2025-01-01T00:00:00.000Z',
eventEndUtc: '2025-01-02T00:00:00.000Z',
secondsToStart: null,
secondsToEnd: 43200,
}),
};
const svc = new ScoreboardService(settings, eventStatus);
const mgr = buildMockManager({
solves: [
{ id: 's1', challengeId: 'c1', userId: 'u-a', solvedAt: '2025-01-01T01:00:00.000Z', pointsAwarded: 115, username: 'a' },
{ id: 's2', challengeId: 'c2', userId: 'u-a', solvedAt: '2025-01-01T02:00:00.000Z', pointsAwarded: 60, username: 'a' },
],
challenges: [
{ id: 'c1', name: 'alpha', initialPoints: 500, minimumPoints: 100, decaySolves: 10, categoryAbbreviation: 'CRY' },
{ id: 'c2', name: 'beta', initialPoints: 300, minimumPoints: 50, decaySolves: 5, categoryAbbreviation: 'WEB' },
],
});
const graph = await svc.getGraph(mgr);
expect(graph.series).toHaveLength(1);
const s = graph.series[0];
expect(s.playerId).toBe('u-a');
expect(s.points[0]).toEqual({ tUtc: '2025-01-01T00:00:00.000Z', value: 0 });
expect(s.points[s.points.length - 1]).toEqual({ tUtc: '2025-01-02T00:00:00.000Z', value: 515 + 315 });
});
it('returns empty series when event not configured', async () => {
const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
const graph = await svc.getGraph(buildMockManager({}));
expect(graph.series).toEqual([]);
expect(graph.startUtc).toBeNull();
expect(graph.endUtc).toBeNull();
});
});
describe('ScoreboardService.getEventLog', () => {
it('returns rows with computed position from same-challenge order', async () => {
const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
const solves = [
{ id: 's1', challengeId: 'c1', userId: 'u-a', solvedAt: '2025-01-01T00:00:00.000Z', pointsAwarded: 115, basePoints: 100, rankBonus: 15, username: 'a', challengeName: 'alpha', categoryAbbreviation: 'CRY' },
{ id: 's2', challengeId: 'c1', userId: 'u-b', solvedAt: '2025-01-01T00:01:00.000Z', pointsAwarded: 110, basePoints: 100, rankBonus: 10, username: 'b', challengeName: 'alpha', categoryAbbreviation: 'CRY' },
];
const mgr = buildMockManager({ solves });
const log = await svc.getEventLog(mgr, 10);
expect(log).toHaveLength(2);
const bySolveId = new Map(log.map((r: any) => [r.solveId, r]));
expect((bySolveId.get('s1') as any).position).toBe(1);
expect((bySolveId.get('s2') as any).position).toBe(2);
});
});
+142
View File
@@ -0,0 +1,142 @@
jest.mock('@angular/common/http', () => ({
HttpClient: class {},
HttpErrorResponse: class {},
}));
import {
PLAYER_COLOR_PALETTE,
RankingRow,
applyRankingSort,
applySolveToGraph,
dedupEventLogBySolveId,
formatSolveDateTime,
mutateMatrixFromSolve,
parseSolveEventIntoRanking,
stablePlayerColorIndex,
} from '../../frontend/src/app/features/scoreboard/scoreboard.pure';
describe('stablePlayerColorIndex', () => {
it('is deterministic and in range', () => {
const a = stablePlayerColorIndex('player-abc');
expect(a).toBeGreaterThanOrEqual(0);
expect(a).toBeLessThan(PLAYER_COLOR_PALETTE.length);
expect(stablePlayerColorIndex('player-abc')).toBe(a);
expect(stablePlayerColorIndex('player-xyz')).not.toBe(a);
});
});
describe('applyRankingSort', () => {
it('produces competition ranks with zero-pt rows at the bottom', () => {
const rows: RankingRow[] = [
{ rank: 0, playerId: 'p1', playerName: 'p1', solvedCount: 2, points: 0, colorIndex: 0 },
{ rank: 0, playerId: 'p2', playerName: 'p2', solvedCount: 1, points: 100, colorIndex: 1 },
{ rank: 0, playerId: 'p3', playerName: 'p3', solvedCount: 1, points: 50, colorIndex: 2 },
{ rank: 0, playerId: 'p4', playerName: 'p4', solvedCount: 0, points: 0, colorIndex: 3 },
];
const sorted = applyRankingSort(rows);
expect(sorted.map((r) => r.playerId)).toEqual(['p2', 'p3', 'p1', 'p4']);
expect(sorted.map((r) => r.rank)).toEqual([1, 2, 3, 3]);
});
});
describe('parseSolveEventIntoRanking', () => {
it('adds a new player and re-sorts', () => {
const rows: RankingRow[] = [
{ rank: 1, playerId: 'p1', playerName: 'p1', solvedCount: 2, points: 100, colorIndex: 0 },
];
const next = parseSolveEventIntoRanking(rows, {
challengeId: 'c1',
playerId: 'p2',
playerName: 'p2',
awardedPoints: 50,
rankBonus: 0,
awardedAtUtc: '2025-01-01T00:00:00.000Z',
position: 4,
});
expect(next.length).toBe(2);
const p2 = next.find((r) => r.playerId === 'p2')!;
expect(p2.points).toBe(50);
expect(p2.solvedCount).toBe(1);
expect(next[0].playerId).toBe('p1');
});
});
describe('dedupEventLogBySolveId', () => {
it('prepends new rows and removes duplicates', () => {
const existing = [
{ solveId: 's1', playerId: 'p1', playerName: 'p1', challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', position: 1, awardedPoints: 100, awardedAtUtc: '2025-01-01T00:00:00.000Z' },
];
const newRows = [
{ solveId: 's1', playerId: 'p1', playerName: 'p1', challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', position: 1, awardedPoints: 100, awardedAtUtc: '2025-01-01T00:00:00.000Z' },
{ solveId: 's2', playerId: 'p2', playerName: 'p2', challengeId: 'c2', challengeName: 'beta', categoryAbbreviation: 'WEB', position: 1, awardedPoints: 50, awardedAtUtc: '2025-01-01T00:01:00.000Z' },
];
const out = dedupEventLogBySolveId(existing, newRows);
expect(out.length).toBe(2);
expect(out[0].solveId).toBe('s2');
expect(out[1].solveId).toBe('s1');
});
});
describe('applySolveToGraph', () => {
it('appends a new point to the matching series and re-applies the top-10 cutoff', () => {
const baseView = {
startUtc: '2025-01-01T00:00:00.000Z',
endUtc: '2025-01-02T00:00:00.000Z',
serverNowUtc: '2025-01-01T12:00:00.000Z',
state: 'running' as const,
series: [
{ playerId: 'p0', playerName: 'p0', colorIndex: 0, points: [{ tUtc: '2025-01-01T00:00:00.000Z', value: 0 }, { tUtc: '2025-01-02T00:00:00.000Z', value: 50 }] },
],
};
const next = applySolveToGraph(
baseView,
{ challengeId: 'c1', 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',
);
expect(next.series[0].points.length).toBe(3);
expect(next.series[0].points[0]).toEqual({ tUtc: '2025-01-01T00:00:00.000Z', value: 0 });
expect(next.series[0].points[1].tUtc).toBe('2025-01-01T06:00:00.000Z');
expect(next.series[0].points[1].value).toBe(80);
expect(next.series[0].points[2]).toEqual({ tUtc: '2025-01-02T00:00:00.000Z', value: 80 });
});
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 },
null,
null,
'2025-01-01T12:00:00.000Z',
);
expect(next.series).toEqual([]);
});
});
describe('mutateMatrixFromSolve', () => {
it('stores the rank when within top-3', () => {
const matrix = {
players: [{ rank: 1, playerId: 'p1', playerName: 'p1', solvedCount: 1, points: 100, colorIndex: 0 }],
challenges: [{ id: 'c1', name: 'alpha', solveCount: 1, categoryAbbreviation: 'CRY' }],
cells: { p1: { c1: 1 as 1 } },
};
const next = mutateMatrixFromSolve(matrix, {
challengeId: 'c1',
playerId: 'p2',
playerName: 'p2',
awardedPoints: 50,
rankBonus: 0,
awardedAtUtc: '2025-01-01T00:00:00.000Z',
position: 5,
});
expect(next!.cells.p2.c1).toBe('solved');
});
});
describe('formatSolveDateTime', () => {
it('returns empty string for invalid input', () => {
expect(formatSolveDateTime(null)).toBe('');
expect(formatSolveDateTime('not a date')).toBe('');
});
});
+147
View File
@@ -0,0 +1,147 @@
jest.mock('@angular/common/http', () => ({
HttpClient: class {},
HttpErrorResponse: class {},
}));
import { ScoreboardStore } from '../../frontend/src/app/features/scoreboard/scoreboard.store';
import {
EventLogRow,
GraphView,
MatrixView,
RankingRow,
} from '../../frontend/src/app/features/scoreboard/scoreboard.pure';
import { Observable, of } from 'rxjs';
class FakeApi {
rankingCallCount = 0;
matrixCallCount = 0;
eventLogCallCount = 0;
graphCallCount = 0;
getRanking(): Observable<RankingRow[]> {
this.rankingCallCount += 1;
return of<RankingRow[]>([
{ rank: 1, playerId: 'p1', playerName: 'p1', solvedCount: 1, points: 100, colorIndex: 0 },
]);
}
getMatrix(): Observable<MatrixView> {
this.matrixCallCount += 1;
return of<MatrixView>({ players: [], challenges: [], cells: {} });
}
getEventLog(): Observable<EventLogRow[]> {
this.eventLogCallCount += 1;
return of<EventLogRow[]>([
{ solveId: 's1', playerId: 'p1', playerName: 'p1', challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', position: 1, awardedPoints: 100, awardedAtUtc: '2025-01-01T00:00:00.000Z' },
]);
}
getGraph(): Observable<GraphView> {
this.graphCallCount += 1;
return of<GraphView>({
startUtc: '2025-01-01T00:00:00.000Z',
endUtc: '2025-01-02T00:00:00.000Z',
serverNowUtc: '2025-01-01T00:00:00.000Z',
state: 'running',
series: [],
});
}
}
function makeFakeSse(handler: (type: string, payload: any) => void): any {
return {
addEventListener(type: string, fn: (ev: any) => void) {
handler(type, fn);
},
close() {},
};
}
describe('ScoreboardStore', () => {
it('loadAll fetches all four projections once', async () => {
const api = new FakeApi();
const destroyRef: any = { onDestroy: () => {} };
const store = new ScoreboardStore(api as any, destroyRef);
await store.loadAll();
expect(api.rankingCallCount).toBe(1);
expect(api.matrixCallCount).toBe(1);
expect(api.eventLogCallCount).toBe(1);
expect(api.graphCallCount).toBe(1);
await store.loadAll();
expect(api.rankingCallCount).toBe(1);
});
it('applySolveEvent mutates ranking, eventLog, graph and matrix', () => {
const api = new FakeApi();
const destroyRef: any = { onDestroy: () => {} };
const store = new ScoreboardStore(api as any, destroyRef);
void store.loadAll();
return new Promise<void>((resolve) => {
const source = makeFakeSse((type, fn) => {
if (type === 'solve') {
setTimeout(() => {
fn(new MessageEvent('solve', { data: JSON.stringify({
challenge_id: 'c2',
player_id: 'p1',
player_name: 'p1',
awarded_points: 25,
rank_bonus: 0,
awarded_at_utc: '2025-01-01T01:00:00.000Z',
position: 4,
}) }));
setTimeout(() => {
expect(store.ranking()?.[0]?.points).toBe(125);
expect(store.eventLog()?.[0]?.awardedPoints).toBe(25);
expect(store.graph()?.series[0]?.playerId).toBe('p1');
resolve();
}, 0);
}, 0);
}
});
store.wireSse(() => source);
});
});
it('does not duplicate event-log rows on replayed solve frame', () => {
const api = new FakeApi();
const destroyRef: any = { onDestroy: () => {} };
const store = new ScoreboardStore(api as any, destroyRef);
void store.loadAll();
return new Promise<void>((resolve) => {
let fire: ((ev: any) => void) | null = null;
const source = makeFakeSse((type, fn) => {
if (type === 'solve') fire = fn;
});
store.wireSse(() => source);
const payload = {
challenge_id: 'c2',
player_id: 'p1',
player_name: 'p1',
awarded_points: 25,
rank_bonus: 0,
awarded_at_utc: '2025-01-01T01:00:00.000Z',
position: 4,
};
setTimeout(() => {
fire!(new MessageEvent('solve', { data: JSON.stringify(payload) }));
fire!(new MessageEvent('solve', { data: JSON.stringify(payload) }));
setTimeout(() => {
const log = store.eventLog() ?? [];
const p1c2 = log.filter((r) => r.playerId === 'p1' && r.challengeId === 'c2');
expect(p1c2.length).toBe(1);
resolve();
}, 0);
}, 0);
});
});
it('reset clears all signals and stops SSE', () => {
const api = new FakeApi();
const destroyRef: any = { onDestroy: () => {} };
const store = new ScoreboardStore(api as any, destroyRef);
void store.loadAll();
store.reset();
expect(store.ranking()).toBeNull();
expect(store.matrix()).toBeNull();
expect(store.eventLog()).toBeNull();
expect(store.graph()).toBeNull();
});
});