feat: Scoreboard: Ranking, Matrix, Event Log and Score Graph

This commit is contained in:
OpenVelo Agent
2026-07-23 05:13:21 +00:00
parent 1576d663d3
commit d5c32bb3c8
20 changed files with 2538 additions and 32 deletions
@@ -0,0 +1,77 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { EventLogRow, formatSolveDateTime } from './scoreboard.pure';
@Component({
selector: 'app-scoreboard-event-log',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
.empty { padding: 32px; text-align: center; opacity: 0.7; }
ol {
list-style: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 6px;
max-height: 70vh; overflow: auto;
}
li {
display: flex; align-items: center; gap: 12px;
padding: 8px 10px;
background: var(--color-surface, #1c1f26);
border: 1px solid var(--color-border, #2a2f3a);
border-radius: var(--radius-sm, 4px);
font-size: 13px;
}
.icon { font-size: 18px; min-width: 22px; text-align: center; }
.icon-gold { color: #d4a017; }
.icon-silver { color: #9aa3b0; }
.icon-bronze { color: #b87333; }
.icon-check { color: var(--color-success, #22c55e); }
.time { opacity: 0.7; font-variant-numeric: tabular-nums; min-width: 170px; }
.who { font-weight: 600; min-width: 120px; }
.what { flex: 1; min-width: 0; }
.abbrev { opacity: 0.65; margin-right: 6px; font-size: 11px; }
.pts { font-weight: 600; font-variant-numeric: tabular-nums; }
`],
template: `
<ng-container *ngIf="rows?.length; else empty">
<ol data-testid="scoreboard-event-log">
<li *ngFor="let r of rows; trackBy: trackBySolve" [attr.data-testid]="'event-log-row-' + r.solveId">
<span [class]="iconClass(r)" class="icon" aria-hidden="true">{{ icon(r) }}</span>
<span class="time">{{ format(r.awardedAtUtc) }}</span>
<span class="who">{{ r.playerName }}</span>
<span class="what">
<span class="abbrev" *ngIf="r.categoryAbbreviation">{{ r.categoryAbbreviation }}</span>
{{ r.challengeName }}
</span>
<span class="pts">+{{ r.awardedPoints }}</span>
</li>
</ol>
</ng-container>
<ng-template #empty>
<p class="empty" data-testid="event-log-empty">No solves yet</p>
</ng-template>
`,
})
export class EventLogComponent {
@Input() rows: EventLogRow[] | null = [];
format = formatSolveDateTime;
icon(r: EventLogRow): string {
if (r.position === 1) return '★';
if (r.position === 2) return '★';
if (r.position === 3) return '★';
return '✓';
}
iconClass(r: EventLogRow): string {
if (r.position === 1) return 'icon-gold';
if (r.position === 2) return 'icon-silver';
if (r.position === 3) return 'icon-bronze';
return 'icon-check';
}
trackBySolve = (_: number, r: EventLogRow) => r.solveId || `${r.playerId}|${r.challengeId}|${r.awardedAtUtc}`;
}
@@ -0,0 +1,114 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PLAYER_COLOR_PALETTE, MatrixView } from './scoreboard.pure';
@Component({
selector: 'app-scoreboard-matrix',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
.empty { padding: 32px; text-align: center; opacity: 0.7; }
.scroll {
overflow: auto;
max-height: 70vh;
max-width: 100%;
border: 1px solid var(--color-border, #2a2f3a);
border-radius: var(--radius-sm, 4px);
background: var(--color-surface, #1c1f26);
}
table {
border-collapse: collapse;
width: max-content;
min-width: 100%;
}
th, td {
padding: 6px 10px;
border-bottom: 1px solid var(--color-border, #2a2f3a);
border-right: 1px solid var(--color-border, #2a2f3a);
font-size: 13px;
text-align: center;
min-width: 56px;
white-space: nowrap;
}
th.sticky-x, td.sticky-x {
position: sticky;
left: 0;
background: var(--color-surface, #1c1f26);
z-index: 1;
text-align: left;
min-width: 160px;
}
thead th {
position: sticky;
top: 0;
background: var(--color-surface, #1c1f26);
z-index: 2;
}
thead th.sticky-x { z-index: 3; }
.abbrev { opacity: 0.65; margin-right: 6px; font-size: 11px; }
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
.cell-gold { color: #d4a017; }
.cell-silver { color: #9aa3b0; }
.cell-bronze { color: #b87333; }
.cell-check { color: var(--color-success, #22c55e); }
`],
template: `
<ng-container *ngIf="view && view.players.length; else empty">
<div class="scroll" data-testid="scoreboard-matrix-scroll">
<table data-testid="scoreboard-matrix">
<thead>
<tr>
<th class="sticky-x" data-testid="matrix-player-header">Player</th>
<th
*ngFor="let ch of view.challenges; trackBy: trackByCh"
[attr.data-testid]="'matrix-col-' + ch.id"
>
<span class="abbrev" *ngIf="ch.categoryAbbreviation">{{ ch.categoryAbbreviation }}</span>
<span>{{ ch.name }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let p of view.players; trackBy: trackByPlayer"
[attr.data-testid]="'matrix-row-' + p.playerId"
>
<td class="sticky-x">
<span class="swatch" [style.background-color]="color(p.colorIndex)" aria-hidden="true"></span>
{{ p.playerName }}
</td>
<td
*ngFor="let ch of view.challenges; trackBy: trackByCh"
[attr.data-testid]="'matrix-cell-' + p.playerId + '-' + ch.id"
>
<ng-container [ngSwitch]="view.cells[p.playerId]?.[ch.id]">
<span *ngSwitchCase="1" class="cell-gold" title="1st solver">★</span>
<span *ngSwitchCase="2" class="cell-silver" title="2nd solver">★</span>
<span *ngSwitchCase="3" class="cell-bronze" title="3rd solver">★</span>
<span *ngSwitchCase="'solved'" class="cell-check" title="solved">✓</span>
<span *ngSwitchDefault></span>
</ng-container>
</td>
</tr>
</tbody>
</table>
</div>
</ng-container>
<ng-template #empty>
<p class="empty" data-testid="matrix-empty">No players yet</p>
</ng-template>
`,
})
export class MatrixComponent {
@Input() view: MatrixView | null = null;
color(idx: number): string {
const safe = ((idx % PLAYER_COLOR_PALETTE.length) + PLAYER_COLOR_PALETTE.length) % PLAYER_COLOR_PALETTE.length;
return PLAYER_COLOR_PALETTE[safe];
}
trackByPlayer = (_: number, p: { playerId: string }) => p.playerId;
trackByCh = (_: number, ch: { id: string }) => ch.id;
}
@@ -0,0 +1,73 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PLAYER_COLOR_PALETTE, RankingRow } from './scoreboard.pure';
@Component({
selector: 'app-scoreboard-ranking',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
.empty { padding: 32px; text-align: center; opacity: 0.7; }
table {
width: 100%;
border-collapse: collapse;
background: var(--color-surface, #1c1f26);
border-radius: var(--radius-sm, 4px);
overflow: hidden;
}
th, td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--color-border, #2a2f3a);
}
th { font-weight: 600; opacity: 0.85; }
.rank { width: 64px; }
.points { text-align: right; font-variant-numeric: tabular-nums; }
.solved { text-align: right; font-variant-numeric: tabular-nums; }
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 8px; vertical-align: middle; }
`],
template: `
<ng-container *ngIf="rows?.length; else empty">
<table data-testid="scoreboard-ranking">
<thead>
<tr>
<th class="rank" aria-sort="none" data-testid="ranking-rank-header">Rank</th>
<th>Player</th>
<th class="solved" data-testid="ranking-solved-header">Solved</th>
<th class="points" data-testid="ranking-points-header">Points</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let row of rows; trackBy: trackById"
[attr.data-testid]="'ranking-row-' + row.playerId"
[attr.data-rank]="row.rank"
>
<td class="rank">{{ row.rank }}</td>
<td>
<span class="swatch" [style.background-color]="color(row.colorIndex)" aria-hidden="true"></span>
{{ row.playerName }}
</td>
<td class="solved">{{ row.solvedCount }}</td>
<td class="points">{{ row.points }}</td>
</tr>
</tbody>
</table>
</ng-container>
<ng-template #empty>
<p class="empty" data-testid="ranking-empty">No players yet</p>
</ng-template>
`,
})
export class RankingComponent {
@Input() rows: RankingRow[] | null = [];
color(idx: number): string {
const safe = ((idx % PLAYER_COLOR_PALETTE.length) + PLAYER_COLOR_PALETTE.length) % PLAYER_COLOR_PALETTE.length;
return PLAYER_COLOR_PALETTE[safe];
}
trackById = (_: number, row: RankingRow) => row.playerId;
}
@@ -0,0 +1,137 @@
import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { GraphView, PLAYER_COLOR_PALETTE } from './scoreboard.pure';
interface Point { x: number; y: number; }
@Component({
selector: 'app-scoreboard-graph',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
.empty, .not-started {
padding: 32px; text-align: center; opacity: 0.7;
}
.legend {
display: flex; flex-wrap: wrap; gap: 8px 12px;
margin-bottom: 8px;
font-size: 12px;
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.swatch { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.frame {
border: 1px solid var(--color-border, #2a2f3a);
border-radius: var(--radius-sm, 4px);
background: var(--color-surface, #1c1f26);
padding: 8px;
}
svg { display: block; width: 100%; height: auto; }
`],
template: `
<ng-container [ngSwitch]="state()">
<ng-container *ngSwitchCase="'unconfigured'">
<p class="empty" data-testid="score-graph-empty">Awaiting event configuration</p>
</ng-container>
<ng-container *ngSwitchCase="'countdown'">
<p class="not-started" data-testid="score-graph-not-started">Not started</p>
</ng-container>
<ng-container *ngSwitchDefault>
<ng-container *ngIf="view && view.series.length; else emptySeries">
<div class="frame" data-testid="score-graph">
<div class="legend">
<span
*ngFor="let s of view.series; trackBy: trackByPlayer"
class="legend-item"
[attr.data-testid]="'score-graph-legend-' + s.playerId"
>
<span class="swatch" [style.background-color]="color(s.colorIndex)" aria-hidden="true"></span>
{{ s.playerName }}
</span>
</div>
<svg
[attr.viewBox]="'0 0 ' + width + ' ' + height"
preserveAspectRatio="xMidYMid meet"
data-testid="score-graph-svg"
>
<g class="axes">
<line [attr.x1]="padL" [attr.y1]="height - padB" [attr.x2]="width - padR" [attr.y2]="height - padB" stroke="currentColor" stroke-opacity="0.3" />
<line [attr.x1]="padL" [attr.y1]="padT" [attr.x2]="padL" [attr.y2]="height - padB" stroke="currentColor" stroke-opacity="0.3" />
<text [attr.x]="padL" [attr.y]="padT - 4" font-size="10" fill="currentColor" fill-opacity="0.6">pts</text>
<text [attr.x]="width - padR" [attr.y]="height - 4" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.6">time</text>
<text [attr.x]="padL - 4" [attr.y]="height - padB" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.6">0</text>
<text [attr.x]="padL - 4" [attr.y]="padT + 4" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.6">{{ maxValue() }}</text>
</g>
<polyline
*ngFor="let s of view.series; trackBy: trackByPlayer"
[attr.points]="pointsAttr(s)"
fill="none"
[attr.stroke]="color(s.colorIndex)"
stroke-width="2"
[attr.data-testid]="'score-graph-line-' + s.playerId"
/>
</svg>
</div>
</ng-container>
<ng-template #emptySeries>
<p class="empty" data-testid="score-graph-empty">No solves yet</p>
</ng-template>
</ng-container>
</ng-container>
`,
})
export class ScoreGraphComponent {
@Input() view: GraphView | null = null;
readonly width = 800;
readonly height = 320;
readonly padL = 40;
readonly padR = 16;
readonly padT = 16;
readonly padB = 28;
readonly state = computed(() => this.view?.state ?? 'unconfigured');
private readonly _maxValue = computed(() => {
const v = this.view;
if (!v) return 0;
let max = 0;
for (const s of v.series) {
for (const p of s.points) {
if (p.value > max) max = p.value;
}
}
return max;
});
maxValue(): number {
return this._maxValue();
}
color(idx: number): string {
const safe = ((idx % PLAYER_COLOR_PALETTE.length) + PLAYER_COLOR_PALETTE.length) % PLAYER_COLOR_PALETTE.length;
return PLAYER_COLOR_PALETTE[safe];
}
pointsAttr(s: { points: { tUtc: string; value: number }[] }): string {
const v = this.view;
if (!v || !v.startUtc || !v.endUtc) return '';
const startMs = new Date(v.startUtc).getTime();
const endMs = new Date(v.endUtc).getTime();
const span = Math.max(1, endMs - startMs);
const maxVal = Math.max(1, this._maxValue());
const innerW = this.width - this.padL - this.padR;
const innerH = this.height - this.padT - this.padB;
return s.points
.map((p) => {
const ms = new Date(p.tUtc).getTime();
const x = this.padL + ((ms - startMs) / span) * innerW;
const y = this.padT + innerH - (p.value / maxVal) * innerH;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(' ');
}
trackByPlayer = (_: number, s: { playerId: string }) => s.playerId;
}
@@ -1,14 +1,94 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthService } from '../../core/services/auth.service';
import { AuthenticatedEventSourceService } from '../../core/services/authenticated-event-source.service';
import { ScoreboardStore } from './scoreboard.store';
import { ScoreboardTabsComponent } from './tabs.component';
import { RankingComponent } from './ranking.component';
import { MatrixComponent } from './matrix.component';
import { EventLogComponent } from './event-log.component';
import { ScoreGraphComponent } from './score-graph.component';
@Component({
selector: 'app-scoreboard-page',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ScoreboardTabsComponent,
RankingComponent,
MatrixComponent,
EventLogComponent,
ScoreGraphComponent,
],
styles: [`
:host { display: block; }
.wrap { padding: 12px 0; }
.loading { opacity: 0.7; padding: 12px; }
.error { color: var(--color-danger, #ef4444); padding: 8px; }
h2 { margin: 0 0 12px; }
`],
template: `
<section class="page-scoreboard">
<section class="wrap" data-testid="scoreboard-page">
<h2>Scoreboard</h2>
<p>Live scoreboard will appear here.</p>
<app-scoreboard-tabs
[active]="store.activeTab()"
(select)="store.setActiveTab($event)"
></app-scoreboard-tabs>
<div *ngIf="store.loading()" class="loading" data-testid="scoreboard-loading">Loading…</div>
<div *ngIf="store.error() as e" class="error" data-testid="scoreboard-error">{{ e }}</div>
<app-scoreboard-ranking
*ngIf="store.activeTab() === 'ranking'"
[rows]="store.ranking()"
></app-scoreboard-ranking>
<app-scoreboard-matrix
*ngIf="store.activeTab() === 'matrix'"
[view]="store.matrix()"
></app-scoreboard-matrix>
<app-scoreboard-event-log
*ngIf="store.activeTab() === 'event-log'"
[rows]="store.eventLog()"
></app-scoreboard-event-log>
<app-scoreboard-graph
*ngIf="store.activeTab() === 'graph'"
[view]="store.graph()"
></app-scoreboard-graph>
</section>
`,
})
export class ScoreboardPage {}
export class ScoreboardPage implements OnInit, OnDestroy {
readonly store = inject(ScoreboardStore);
private readonly auth = inject(AuthService);
private readonly authenticatedEventSource = inject(AuthenticatedEventSourceService);
private readonly destroyRef = inject(DestroyRef);
private sseWired = false;
ngOnInit(): void {
void this.store.loadAll();
if (!this.sseWired) {
this.sseWired = true;
this.store.wireSse(
() => this.authenticatedEventSource.open('/api/v1/events'),
);
}
}
ngOnDestroy(): void {
this.store.stop();
}
}
@@ -0,0 +1,261 @@
export type ScoreboardTab = 'ranking' | 'matrix' | 'event-log' | 'graph';
export const SCOREBOARD_TABS: ScoreboardTab[] = ['ranking', 'matrix', 'event-log', 'graph'];
export interface RankingRow {
rank: number;
playerId: string;
playerName: string;
solvedCount: number;
points: number;
colorIndex: number;
}
export interface MatrixChallengeHeader {
id: string;
name: string;
solveCount: number;
categoryAbbreviation: string;
}
export type MatrixCellRank = 1 | 2 | 3 | 'solved' | null;
export interface MatrixView {
players: RankingRow[];
challenges: MatrixChallengeHeader[];
cells: Record<string, Record<string, MatrixCellRank>>;
}
export interface EventLogRow {
solveId: string;
playerId: string;
playerName: string;
challengeId: string;
challengeName: string;
categoryAbbreviation: string;
position: number;
awardedPoints: number;
awardedAtUtc: string;
}
export interface GraphPoint {
tUtc: string;
value: number;
}
export interface GraphSeries {
playerId: string;
playerName: string;
colorIndex: number;
points: GraphPoint[];
}
export interface GraphView {
startUtc: string | null;
endUtc: string | null;
serverNowUtc: string;
state: 'running' | 'countdown' | 'stopped' | 'unconfigured';
series: GraphSeries[];
}
export interface SolveLivePayload {
challengeId: string;
playerId: string;
playerName: string;
awardedPoints: number;
rankBonus: number;
awardedAtUtc: string;
position: number;
}
export const PLAYER_COLOR_PALETTE: ReadonlyArray<string> = [
'#f59e0b',
'#3b82f6',
'#10b981',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
'#6366f1',
'#84cc16',
];
export function stablePlayerColorIndex(playerId: string): number {
if (!playerId) return 0;
let hash = 0x811c9dc5;
for (let i = 0; i < playerId.length; i += 1) {
hash ^= playerId.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
const n = PLAYER_COLOR_PALETTE.length;
return ((hash % n) + n) % n;
}
export function applyRankingSort(rows: readonly RankingRow[]): RankingRow[] {
const list = rows.map((r) => ({ ...r }));
list.sort((a, b) => {
if (a.points !== b.points) return b.points - a.points;
return 0;
});
let displayedRank = 0;
let lastPoints = Number.NaN;
list.forEach((row, idx) => {
if (row.points !== lastPoints) {
displayedRank = idx + 1;
lastPoints = row.points;
}
row.rank = displayedRank;
});
return list;
}
export function parseSolveEventIntoRanking(
rows: readonly RankingRow[],
payload: SolveLivePayload,
): RankingRow[] {
const out = rows.map((r) => ({ ...r }));
let found = false;
for (const r of out) {
if (r.playerId === payload.playerId) {
r.points += payload.awardedPoints;
r.solvedCount += 1;
found = true;
break;
}
}
if (!found) {
out.push({
rank: 0,
playerId: payload.playerId,
playerName: payload.playerName,
solvedCount: 1,
points: payload.awardedPoints,
colorIndex: stablePlayerColorIndex(payload.playerId),
});
}
return applyRankingSort(out);
}
export function dedupEventLogBySolveId(
existing: readonly EventLogRow[],
newRows: readonly EventLogRow[],
): EventLogRow[] {
if (newRows.length === 0) return existing.slice();
const seen = new Set<string>();
const merged: EventLogRow[] = [];
for (let i = newRows.length - 1; i >= 0; i -= 1) {
const r = newRows[i];
const key = r.solveId || `${r.playerId}|${r.challengeId}|${r.awardedAtUtc}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(r);
}
for (const r of existing) {
const key = r.solveId || `${r.playerId}|${r.challengeId}|${r.awardedAtUtc}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(r);
}
return merged;
}
export function applySolveToGraph(
existing: GraphView | null,
payload: SolveLivePayload,
eventStartUtc: string | null,
eventEndUtc: string | null,
serverNowUtc: string,
): GraphView {
const start = eventStartUtc ?? existing?.startUtc ?? null;
const end = eventEndUtc ?? existing?.endUtc ?? null;
const state: GraphView['state'] = existing?.state ?? 'running';
if (!start || !end) {
return {
startUtc: start,
endUtc: end,
serverNowUtc,
state,
series: existing?.series ?? [],
};
}
const seriesList = (existing?.series ?? []).map((s) => ({ ...s, points: [...s.points] }));
let series = seriesList.find((s) => s.playerId === payload.playerId);
if (!series) {
series = {
playerId: payload.playerId,
playerName: payload.playerName,
colorIndex: stablePlayerColorIndex(payload.playerId),
points: [{ tUtc: start, value: 0 }],
};
seriesList.push(series);
}
const head = series.points[0];
if (!head || head.tUtc !== start) {
series.points = [{ tUtc: start, value: 0 }, ...series.points];
}
const endIdx = series.points.findIndex((p) => p.tUtc === end);
const prevValue = endIdx >= 0
? series.points[endIdx].value
: (series.points[series.points.length - 1]?.value ?? 0);
const newValue = prevValue + payload.awardedPoints;
if (endIdx >= 0) {
const body = series.points.slice(1, endIdx);
series.points = [
{ tUtc: start, value: 0 },
...body,
{ tUtc: payload.awardedAtUtc, value: newValue },
{ tUtc: end, value: newValue },
];
} else {
series.points = [
...series.points,
{ tUtc: payload.awardedAtUtc, value: newValue },
];
}
const finalValue = (s: GraphSeries) => s.points[s.points.length - 1]?.value ?? 0;
seriesList.sort((a, b) => finalValue(b) - finalValue(a));
const top = seriesList.slice(0, 10);
return { startUtc: start, endUtc: end, serverNowUtc, state, series: top };
}
export function mutateMatrixFromSolve(
matrix: MatrixView | null,
payload: SolveLivePayload,
): MatrixView | null {
if (!matrix) return matrix;
const players = matrix.players.map((p) => ({ ...p }));
let player = players.find((p) => p.playerId === payload.playerId);
if (!player) {
player = {
rank: 0,
playerId: payload.playerId,
playerName: payload.playerName,
solvedCount: 1,
points: payload.awardedPoints,
colorIndex: stablePlayerColorIndex(payload.playerId),
};
players.push(player);
}
const cells = { ...matrix.cells };
const cellRow = { ...(cells[payload.playerId] ?? {}) };
if (payload.position >= 1 && payload.position <= 3) {
cellRow[payload.challengeId] = payload.position as 1 | 2 | 3;
} else if (payload.position >= 4) {
cellRow[payload.challengeId] = 'solved';
}
cells[payload.playerId] = cellRow;
return { players, challenges: matrix.challenges, cells };
}
export function formatSolveDateTime(utc: string | null | undefined): string {
if (!utc) return '';
const d = new Date(utc);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
@@ -0,0 +1,60 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, catchError, throwError } from 'rxjs';
import {
EventLogRow,
GraphView,
MatrixView,
RankingRow,
} from './scoreboard.pure';
export interface ApiErrorEnvelope {
status: number;
code?: string;
message?: string;
details?: unknown;
}
function toEnvelope(err: unknown): ApiErrorEnvelope {
if (err instanceof HttpErrorResponse) {
return {
status: err.status,
code: err.error?.code,
message: err.error?.message,
details: err.error?.details,
};
}
return { status: 0, code: 'NETWORK', message: (err as Error)?.message };
}
@Injectable({ providedIn: 'root' })
export class ScoreboardApiService {
private readonly http = inject(HttpClient);
getRanking(): Observable<RankingRow[]> {
return this.http
.get<RankingRow[]>('/api/v1/scoreboard/ranking', { withCredentials: true })
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
}
getMatrix(): Observable<MatrixView> {
return this.http
.get<MatrixView>('/api/v1/scoreboard/matrix', { withCredentials: true })
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
}
getEventLog(limit = 50): Observable<EventLogRow[]> {
return this.http
.get<EventLogRow[]>('/api/v1/scoreboard/event-log', {
withCredentials: true,
params: { limit: String(limit) },
})
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
}
getGraph(): Observable<GraphView> {
return this.http
.get<GraphView>('/api/v1/scoreboard/graph', { withCredentials: true })
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
}
}
@@ -0,0 +1,210 @@
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
import { ScoreboardApiService } from './scoreboard.service';
import {
EventLogRow,
GraphView,
MatrixView,
RankingRow,
SCOREBOARD_TABS,
ScoreboardTab,
SolveLivePayload,
applySolveToGraph,
dedupEventLogBySolveId,
mutateMatrixFromSolve,
parseSolveEventIntoRanking,
} from './scoreboard.pure';
import { EventSourceLike } from '../../core/services/event-status.pure';
@Injectable({ providedIn: 'root' })
export class ScoreboardStore {
private readonly api: ScoreboardApiService;
private readonly destroyRef: DestroyRef;
private readonly _ranking = signal<RankingRow[] | null>(null);
private readonly _matrix = signal<MatrixView | null>(null);
private readonly _eventLog = signal<EventLogRow[] | null>(null);
private readonly _graph = signal<GraphView | null>(null);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
private readonly _activeTab = signal<ScoreboardTab>('ranking');
private sse: EventSourceLike | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectDelayMs = 1000;
private createSource: (() => EventSourceLike) | null = null;
private loadAllInFlight: Promise<void> | null = null;
constructor(api?: ScoreboardApiService, destroyRef?: DestroyRef) {
this.api = api ?? inject(ScoreboardApiService);
this.destroyRef = destroyRef ?? inject(DestroyRef);
this.destroyRef.onDestroy(() => this.stop());
}
readonly ranking = this._ranking.asReadonly();
readonly matrix = this._matrix.asReadonly();
readonly eventLog = this._eventLog.asReadonly();
readonly graph = this._graph.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly activeTab = this._activeTab.asReadonly();
readonly tabs = computed<ScoreboardTab[]>(() => [...SCOREBOARD_TABS]);
setActiveTab(tab: ScoreboardTab): void {
this._activeTab.set(tab);
}
async loadAll(): Promise<void> {
if (this.loadAllInFlight) return this.loadAllInFlight;
if (this._ranking() && this._matrix() && this._eventLog() && this._graph()) {
return;
}
this._loading.set(true);
this._error.set(null);
this.loadAllInFlight = (async () => {
try {
const ranking = await this.toPromise<RankingRow[]>(() => this.api.getRanking());
const matrix = await this.toPromise<MatrixView>(() => this.api.getMatrix());
const eventLog = await this.toPromise<EventLogRow[]>(() => this.api.getEventLog(50));
const graph = await this.toPromise<GraphView>(() => this.api.getGraph());
this._ranking.set(ranking);
this._matrix.set(matrix);
this._eventLog.set(eventLog);
this._graph.set(graph);
} catch (err: any) {
this._error.set(err?.message ?? 'Failed to load scoreboard');
} finally {
this._loading.set(false);
this.loadAllInFlight = null;
}
})();
return this.loadAllInFlight;
}
wireSse(createSource: () => EventSourceLike, onUnauthorized?: () => void): void {
this.stop();
this.createSource = createSource;
this.openSource(onUnauthorized);
}
handleSseFrame(ev: MessageEvent | Event): void {
const me = ev as MessageEvent;
if (!me || me.type !== 'solve') return;
try {
const raw = typeof me.data === 'string' ? JSON.parse(me.data) : me.data;
if (!raw) return;
const payload: SolveLivePayload = {
challengeId: raw?.challenge_id ?? raw?.challengeId ?? '',
playerId: raw?.player_id ?? raw?.playerId ?? raw?.userId ?? '',
playerName: raw?.player_name ?? raw?.playerName ?? '',
awardedPoints: Number(raw?.awarded_points ?? raw?.awardedPoints ?? raw?.pointsAwarded ?? 0),
rankBonus: Number(raw?.rank_bonus ?? raw?.rankBonus ?? 0),
awardedAtUtc: raw?.awarded_at_utc ?? raw?.awardedAtUtc ?? raw?.solvedAt ?? '',
position: Number(raw?.position ?? 0),
};
this.applySolveEvent(payload);
} catch {
// ignore malformed frame
}
}
applySolveEvent(payload: SolveLivePayload): void {
const ranking = this._ranking();
if (ranking) {
this._ranking.set(parseSolveEventIntoRanking(ranking, payload));
}
const eventLog = this._eventLog();
if (eventLog) {
const newRow: EventLogRow = {
solveId: '',
playerId: payload.playerId,
playerName: payload.playerName,
challengeId: payload.challengeId,
challengeName: '',
categoryAbbreviation: '',
position: payload.position,
awardedPoints: payload.awardedPoints,
awardedAtUtc: payload.awardedAtUtc,
};
this._eventLog.set(dedupEventLogBySolveId(eventLog, [newRow]).slice(0, 200));
}
const graph = this._graph();
if (graph) {
const next = applySolveToGraph(graph, payload, graph.startUtc, graph.endUtc, graph.serverNowUtc);
this._graph.set(next);
}
const matrix = this._matrix();
if (matrix) {
this._matrix.set(mutateMatrixFromSolve(matrix, payload));
}
}
reset(): void {
this.stop();
this._ranking.set(null);
this._matrix.set(null);
this._eventLog.set(null);
this._graph.set(null);
this._error.set(null);
this._loading.set(false);
this._activeTab.set('ranking');
}
stop(): void {
if (this.sse) {
try {
this.sse.close();
} catch {
// ignore
}
this.sse = null;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.createSource = null;
this.reconnectDelayMs = 1000;
}
private openSource(onUnauthorized?: () => void): void {
if (!this.createSource) return;
const src = this.createSource();
this.sse = src;
src.addEventListener('solve', (ev) => this.handleSseFrame(ev));
src.addEventListener('error', () => {
if (this.sse === src) {
try { src.close(); } catch { /* ignore */ }
this.sse = null;
this.scheduleReconnect(onUnauthorized);
}
});
if (onUnauthorized) {
src.addEventListener('unauthorized', () => {
try {
onUnauthorized();
} catch {
// ignore
}
});
}
}
private scheduleReconnect(onUnauthorized?: () => void): void {
if (!this.createSource) return;
if (this.reconnectTimer) return;
const delay = Math.min(this.reconnectDelayMs, 30_000);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.createSource) return;
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, 30_000);
this.openSource(onUnauthorized);
}, delay);
}
private toPromise<T>(fn: () => any): Promise<T> {
return new Promise<T>((resolve, reject) => {
fn().subscribe({ next: (v: T) => resolve(v), error: reject });
});
}
}
@@ -0,0 +1,63 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SCOREBOARD_TABS, ScoreboardTab } from './scoreboard.pure';
interface TabSpec {
id: ScoreboardTab;
label: string;
}
const TAB_LABELS: Readonly<Record<ScoreboardTab, string>> = {
ranking: 'Ranking',
matrix: 'Matrix',
'event-log': 'Event Log',
graph: 'Score Graph',
};
@Component({
selector: 'app-scoreboard-tabs',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
nav {
display: flex; gap: 6px;
border-bottom: 1px solid var(--color-border, #2a2f3a);
padding-bottom: 6px;
margin-bottom: 12px;
overflow-x: auto;
}
button {
padding: 6px 12px;
border-radius: var(--radius-sm, 4px);
background: var(--color-surface, #1c1f26);
color: inherit;
border: 1px solid var(--color-border, #2a2f3a);
cursor: pointer;
font: inherit;
}
button.active {
background: var(--color-primary, #3b82f6);
color: var(--color-primary-contrast, #fff);
border-color: var(--color-primary, #3b82f6);
}
`],
template: `
<nav data-testid="scoreboard-tabs">
<button
*ngFor="let t of tabs"
type="button"
[class.active]="t.id === active"
[attr.data-testid]="'scoreboard-tab-' + t.id"
(click)="select.emit(t.id)"
>{{ t.label }}</button>
</nav>
`,
})
export class ScoreboardTabsComponent {
@Input({ required: true }) active!: ScoreboardTab;
@Output() select = new EventEmitter<ScoreboardTab>();
readonly tabs: TabSpec[] = SCOREBOARD_TABS.map((id) => ({ id, label: TAB_LABELS[id] }));
}