feat: Scoreboard: Ranking, Matrix, Event Log and Score Graph
This commit is contained in:
@@ -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] }));
|
||||
}
|
||||
Reference in New Issue
Block a user