feat: Scoreboard: Ranking, Matrix, Event Log and Score Graph
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 { SolveEntity } from '../../database/entities/solve.entity';
|
||||||
import { UserEntity } from '../../database/entities/user.entity';
|
import { UserEntity } from '../../database/entities/user.entity';
|
||||||
import { CommonModule } from '../../common/common.module';
|
import { CommonModule } from '../../common/common.module';
|
||||||
|
import { SettingsModule } from '../settings/settings.module';
|
||||||
import { ChallengesController } from './challenges.controller';
|
import { ChallengesController } from './challenges.controller';
|
||||||
import { ChallengesEventsController } from './events.controller';
|
import { ChallengesEventsController } from './events.controller';
|
||||||
import { ChallengesService } from './challenges.service';
|
import { ChallengesService } from './challenges.service';
|
||||||
|
import { ScoreboardController } from './scoreboard.controller';
|
||||||
|
import { ScoreboardService } from './scoreboard.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([ChallengeEntity, CategoryEntity, SolveEntity, UserEntity]),
|
TypeOrmModule.forFeature([ChallengeEntity, CategoryEntity, SolveEntity, UserEntity]),
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
SettingsModule,
|
||||||
],
|
],
|
||||||
controllers: [ChallengesController, ChallengesEventsController],
|
controllers: [ChallengesController, ChallengesEventsController, ScoreboardController],
|
||||||
providers: [ChallengesService],
|
providers: [ChallengesService, ScoreboardService],
|
||||||
})
|
})
|
||||||
export class ChallengesModule {}
|
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;
|
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 {
|
export function safeEqualString(a: string, b: string): boolean {
|
||||||
const ab = Buffer.from(a, 'utf8');
|
const ab = Buffer.from(a, 'utf8');
|
||||||
const bb = Buffer.from(b, 'utf8');
|
const bb = Buffer.from(b, 'utf8');
|
||||||
|
|||||||
@@ -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({
|
@Component({
|
||||||
selector: 'app-scoreboard-page',
|
selector: 'app-scoreboard-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
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: `
|
template: `
|
||||||
<section class="page-scoreboard">
|
<section class="wrap" data-testid="scoreboard-page">
|
||||||
<h2>Scoreboard</h2>
|
<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>
|
</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