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:
@@ -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`.
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 1–3 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)
|
||||
|
||||
@@ -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 1–3, otherwise `'solved'`.
|
||||
New players are appended to the matrix row list.
|
||||
* **Event Log**: a new `EventLogRow` is prepended to the list
|
||||
(deduplicated by `solveId` so reconnects don't duplicate) and the
|
||||
list is capped at 200.
|
||||
* **Score Graph**: the matching player's series receives a new
|
||||
`{tUtc: awardedAtUtc, value: prevValue + awardedPoints}` point,
|
||||
the series list is re-sorted by final value DESC and trimmed to the
|
||||
top 10.
|
||||
|
||||
On `ngOnDestroy`, `store.stop()` aborts the SSE transport and clears
|
||||
the reconnect timer so no callbacks fire after the page leaves the
|
||||
DOM.
|
||||
|
||||
# Errors and edge cases
|
||||
|
||||
* **Unauthenticated visit**: the `authGuard` on the parent shell
|
||||
redirects to `/login`; the scoreboard page itself does not handle
|
||||
this case.
|
||||
* **Backend unreachable on initial load**: `store.error()` is set to
|
||||
the failure message and rendered in the page above the active tab
|
||||
(`data-testid="scoreboard-error"`) with the `color-danger` color.
|
||||
The active tab is replaced with nothing visible (the tab
|
||||
components themselves stay in their empty state until the next
|
||||
successful `loadAll`).
|
||||
* **SSE drops**: `ScoreboardStore` schedules a reconnect with an
|
||||
exponential backoff (1s → 2s → 4s → … capped at 30s). When the
|
||||
reconnect succeeds the next `solve` frame re-applies to whatever
|
||||
state the page currently has.
|
||||
* **`401` / `403` SSE response**: dispatched as a separate
|
||||
`'unauthorized'` event by `AuthenticatedEventSourceService`;
|
||||
`ScoreboardStore` accepts an optional `onUnauthorized` callback
|
||||
for the page to plug into the cross-tab invalidation flow (see
|
||||
[Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md)).
|
||||
* **Session change (logout in another tab)**: `HomeComponent`
|
||||
invalidation flow calls `ChallengesStore.reset()` but not the
|
||||
scoreboard store — the scoreboard store is reset automatically by
|
||||
Angular when the `ScoreboardPage` is destroyed as part of the
|
||||
navigation to `/login`.
|
||||
|
||||
# Visual elements
|
||||
|
||||
| Element | Test ID | Notes |
|
||||
|---|---|---|
|
||||
| Page root | `scoreboard-page` | Wrapper section. |
|
||||
| Tab strip | `scoreboard-tabs` | Horizontal `<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.
|
||||
@@ -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
@@ -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] }));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user