UI-Improvements

This commit is contained in:
m0rph3us1987
2026-07-24 12:58:34 +02:00
parent 143ed3c538
commit b99577e76a
40 changed files with 2202 additions and 821 deletions
@@ -8,44 +8,64 @@ import { EventLogRow, formatSolveDateTime } from './scoreboard.pure';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
.empty { padding: 32px; text-align: center; opacity: 0.7; }
:host { display: block; max-width: 840px; margin: 0 auto; }
.empty { padding: 32px; text-align: center; color: #777788; font-family: var(--font-mono); }
ol {
list-style: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 6px;
max-height: 70vh; overflow: auto;
display: flex; flex-direction: column; gap: 10px;
max-height: 75vh; overflow-y: 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;
padding: 12px 16px;
background: #000000;
border: 2px solid #1a1a24;
font-family: var(--font-mono);
font-size: 0.9rem;
color: #ffffff;
transition: border-color 0.15s ease;
}
li:hover {
border-color: var(--neon-purple);
box-shadow: 0 0 10px var(--neon-purple-glow);
}
.icon { font-size: 1.1rem; min-width: 24px; text-align: center; }
.icon-gold { color: #ffb700; }
.icon-silver { color: #c0c0c0; }
.icon-bronze { color: #cd7f32; }
.icon-check { color: var(--neon-green); }
.time { color: #777788; font-size: 0.8rem; }
.who {
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
font-weight: 900;
font-style: italic;
font-size: 1rem;
color: #ffffff;
}
.action { color: #aaaaee; }
.cat { color: var(--neon-purple); font-weight: bold; }
.challenge { color: var(--neon-green); font-weight: bold; }
.pts-box {
margin-left: auto;
border: 2px solid var(--neon-purple);
background: #000000;
color: #ffffff;
font-weight: 800;
padding: 3px 10px;
font-size: 0.85rem;
}
.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="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>
<span class="action">solved</span>
<span class="cat" *ngIf="r.categoryAbbreviation">{{ r.categoryAbbreviation }} -</span>
<span class="challenge">{{ r.challengeName }}</span>
<div class="pts-box">{{ r.awardedPoints }} points</div>
</li>
</ol>
</ng-container>
@@ -60,9 +80,9 @@ export class EventLogComponent {
format = formatSolveDateTime;
icon(r: EventLogRow): string {
if (r.position === 1) return '';
if (r.position === 2) return '';
if (r.position === 3) return '';
if (r.position === 1) return '🏆';
if (r.position === 2) return '🥈';
if (r.position === 3) return '🥉';
return '✓';
}
@@ -8,65 +8,93 @@ import { PLAYER_COLOR_PALETTE, MatrixView } from './scoreboard.pure';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
.empty { padding: 32px; text-align: center; opacity: 0.7; }
.scroll {
:host { display: block; width: 100%; }
.empty { padding: 32px; text-align: center; color: #777788; font-family: var(--font-mono); }
.matrix-container {
background: #000000;
border: 3px solid var(--neon-red);
box-shadow: 0 0 20px rgba(255, 0, 51, 0.25);
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);
max-height: 75vh;
}
table {
border-collapse: collapse;
width: max-content;
min-width: 100%;
font-family: var(--font-mono);
}
th, td {
padding: 6px 10px;
border-bottom: 1px solid var(--color-border, #2a2f3a);
border-right: 1px solid var(--color-border, #2a2f3a);
font-size: 13px;
padding: 8px 10px;
border-bottom: 1px solid #1a1a24;
border-right: 1px solid #1a1a24;
font-size: 12px;
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;
min-width: 48px;
}
thead th {
position: sticky;
top: 0;
background: var(--color-surface, #1c1f26);
background: #0b0b10;
z-index: 2;
vertical-align: bottom;
padding-top: 16px;
padding-bottom: 12px;
height: 180px;
}
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); }
.th-vertical {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-weight: 800;
color: var(--neon-green);
letter-spacing: 0.5px;
white-space: nowrap;
text-align: left;
margin: 0 auto;
max-height: 160px;
}
th.sticky-x, td.sticky-x {
position: sticky;
left: 0;
background: #050508;
z-index: 5;
text-align: left;
min-width: 180px;
font-weight: 800;
}
thead th.sticky-x {
z-index: 10;
vertical-align: middle;
height: auto;
writing-mode: horizontal-tb;
transform: none;
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
font-size: 0.9rem;
font-style: italic;
color: #888899;
}
.swatch {
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
margin-right: 8px; vertical-align: middle;
}
.cell-gold { color: #ffb700; font-size: 14px; font-weight: bold; }
.cell-silver { color: #c0c0c0; font-size: 14px; font-weight: bold; }
.cell-bronze { color: #cd7f32; font-size: 14px; font-weight: bold; }
.cell-check { color: var(--neon-green); font-size: 14px; font-weight: bold; }
`],
template: `
<ng-container *ngIf="view && view.players.length; else empty">
<div class="scroll" data-testid="scoreboard-matrix-scroll">
<div class="matrix-container" data-testid="scoreboard-matrix-scroll">
<table data-testid="scoreboard-matrix">
<thead>
<tr>
<th class="sticky-x" data-testid="matrix-player-header">Player</th>
<th class="sticky-x" data-testid="matrix-player-header">PLAYER / CHALLENGE</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>
<div class="th-vertical">
<span>{{ ch.name }}</span>
</div>
</th>
</tr>
</thead>
@@ -84,11 +112,11 @@ import { PLAYER_COLOR_PALETTE, MatrixView } from './scoreboard.pure';
[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="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>
<span *ngSwitchDefault>·</span>
</ng-container>
</td>
</tr>
@@ -8,53 +8,115 @@ import { PLAYER_COLOR_PALETTE, RankingRow } from './scoreboard.pure';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
.empty { padding: 32px; text-align: center; opacity: 0.7; }
:host { display: block; max-width: 860px; margin: 0 auto; }
.empty { padding: 32px; text-align: center; color: #777788; font-family: var(--font-mono); }
.ranking-container {
background: #000000;
border: 3px solid var(--neon-red);
box-shadow: 0 0 20px rgba(255, 0, 51, 0.25);
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
background: var(--color-surface, #1c1f26);
border-radius: var(--radius-sm, 4px);
overflow: hidden;
font-family: var(--font-mono);
}
th, td {
padding: 8px 12px;
thead th {
background: #111118;
color: #ffffff;
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
font-size: 1.1rem;
font-weight: 900;
font-style: italic;
text-transform: uppercase;
letter-spacing: 1px;
padding: 14px 20px;
border-bottom: 2px solid var(--neon-red);
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; }
tbody tr {
border-bottom: 1px solid #1a1a24;
transition: background-color 0.15s ease;
}
tbody tr:hover {
background: #0d0d14;
}
td {
padding: 14px 20px;
font-size: 1rem;
color: #ffffff;
}
.col-rank { width: 90px; }
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
font-weight: 900;
font-style: italic;
font-size: 1.1rem;
background: #1a1a24;
color: #ffffff;
transform: skewX(-10deg);
}
.rank-1 { background: #ffb700; color: #000000; }
.rank-2 { background: #c0c0c0; color: #000000; }
.rank-3 { background: #cd7f32; color: #000000; }
.col-player { font-weight: 800; font-style: italic; font-size: 1.1rem; }
.swatch {
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
margin-right: 10px; vertical-align: middle;
}
.col-solved {
text-align: center;
color: var(--neon-purple);
font-weight: 800;
font-size: 1.1rem;
}
.col-points {
text-align: right;
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
font-weight: 900;
font-style: italic;
font-size: 1.25rem;
color: #ffffff;
}
`],
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>
<div class="ranking-container">
<table data-testid="scoreboard-ranking">
<thead>
<tr>
<th class="col-rank" aria-sort="none" data-testid="ranking-rank-header">RANK</th>
<th class="col-player">PLAYER</th>
<th class="col-solved" style="text-align:center;" data-testid="ranking-solved-header">SOLVES</th>
<th class="col-points" style="text-align:right;" 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="col-rank">
<span class="rank-badge" [class.rank-1]="row.rank===1" [class.rank-2]="row.rank===2" [class.rank-3]="row.rank===3">
{{ row.rank }}
</span>
</td>
<td class="col-player">
<span class="swatch" [style.background-color]="color(row.colorIndex)" aria-hidden="true"></span>
{{ row.playerName }}
</td>
<td class="col-solved">{{ row.solvedCount }}</td>
<td class="col-points">{{ row.points }}</td>
</tr>
</tbody>
</table>
</div>
</ng-container>
<ng-template #empty>
<p class="empty" data-testid="ranking-empty">No players yet</p>
@@ -9,7 +9,6 @@ import {
projectGraphPoints,
} from './scoreboard.pure';
interface Point { x: number; y: number; }
interface RenderedSeries extends GraphSeries {
pointsAttr: string;
}
@@ -20,22 +19,28 @@ interface RenderedSeries extends GraphSeries {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host { display: block; }
:host { display: block; max-width: 880px; margin: 0 auto; }
.empty, .not-started {
padding: 32px; text-align: center; opacity: 0.7;
padding: 32px; text-align: center; color: #777788; font-family: var(--font-mono);
}
.legend {
display: flex; flex-wrap: wrap; gap: 8px 12px;
margin-bottom: 8px;
font-size: 12px;
display: flex; flex-wrap: wrap; justify-content: center; gap: 10px 16px;
margin-top: 16px;
font-family: var(--font-mono);
font-size: 12px; font-weight: bold;
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-item { display: flex; align-items: center; gap: 6px; color: #ffffff; }
.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;
border: 3px solid var(--neon-red);
background: #000000;
padding: 20px;
box-shadow: 0 0 20px rgba(255, 0, 51, 0.25);
}
.graph-title {
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
font-size: 1.1rem; font-weight: 900; font-style: italic;
text-transform: uppercase; color: #ffffff; margin-bottom: 16px;
}
svg { display: block; width: 100%; height: auto; }
`],
@@ -50,6 +55,32 @@ interface RenderedSeries extends GraphSeries {
<ng-container *ngSwitchDefault>
<ng-container *ngIf="renderedSeries().length; else emptySeries">
<div class="frame" data-testid="score-graph">
<h3 class="graph-title">TOP PLAYERS - SCORE PROGRESSION</h3>
<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="#333344" stroke-width="2" />
<line [attr.x1]="padL" [attr.y1]="padT" [attr.x2]="padL" [attr.y2]="height - padB" stroke="#333344" stroke-width="2" />
<text [attr.x]="padL" [attr.y]="padT - 6" font-size="11" font-weight="bold" fill="#888899">Points</text>
<text [attr.x]="width - padR" [attr.y]="height - 6" text-anchor="end" font-size="11" font-weight="bold" fill="#888899">Time</text>
<text [attr.x]="padL - 6" [attr.y]="height - padB + 4" text-anchor="end" font-size="11" fill="#777788">0</text>
<text [attr.x]="padL - 6" [attr.y]="padT + 4" text-anchor="end" font-size="11" fill="#ffffff" font-weight="bold">{{ maxValue() }}</text>
</g>
<polyline
*ngFor="let s of renderedSeries(); trackBy: trackByPlayer"
[attr.points]="s.pointsAttr"
fill="none"
[attr.stroke]="color(s.colorIndex)"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
[attr.data-testid]="'score-graph-line-' + s.playerId"
/>
</svg>
<div class="legend">
<span
*ngFor="let s of renderedSeries(); trackBy: trackByPlayer"
@@ -60,28 +91,6 @@ interface RenderedSeries extends GraphSeries {
{{ 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 renderedSeries(); trackBy: trackByPlayer"
[attr.points]="s.pointsAttr"
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>
@@ -100,11 +109,11 @@ export class ScoreGraphComponent {
}
readonly width = 800;
readonly height = 320;
readonly padL = 40;
readonly padR = 16;
readonly padT = 16;
readonly padB = 28;
readonly height = 340;
readonly padL = 50;
readonly padR = 20;
readonly padT = 24;
readonly padB = 30;
private readonly _view = signal<GraphView | null>(null);
@@ -5,13 +5,14 @@ import { SCOREBOARD_TABS, ScoreboardTab } from './scoreboard.pure';
interface TabSpec {
id: ScoreboardTab;
label: string;
icon: string;
}
const TAB_LABELS: Readonly<Record<ScoreboardTab, string>> = {
ranking: 'Ranking',
matrix: 'Matrix',
'event-log': 'Event Log',
graph: 'Score Graph',
const TAB_SPECS: Readonly<Record<ScoreboardTab, { label: string; icon: string }>> = {
ranking: { label: 'RANKINGS', icon: '🏆' },
matrix: { label: 'MATRIX', icon: '▦' },
'event-log': { label: 'EVENT LOG', icon: '≡' },
graph: { label: 'SCORE GRAPH', icon: '📈' },
};
@Component({
@@ -22,25 +23,32 @@ const TAB_LABELS: Readonly<Record<ScoreboardTab, string>> = {
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;
display: flex; gap: 10px; justify-content: center;
margin-bottom: 24px; flex-wrap: wrap;
}
button {
padding: 6px 12px;
border-radius: var(--radius-sm, 4px);
background: var(--color-surface, #1c1f26);
color: inherit;
border: 1px solid var(--color-border, #2a2f3a);
padding: 10px 20px;
background: #000000;
color: #aaaaee;
border: 2px solid #22222e;
cursor: pointer;
font: inherit;
font-family: var(--font-mono);
font-weight: 800;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex; align-items: center; gap: 8px;
transition: all 0.15s ease;
}
button:hover:not(.active) {
border-color: var(--neon-purple);
color: #ffffff;
}
button.active {
background: var(--color-primary, #3b82f6);
color: var(--color-primary-contrast, #fff);
border-color: var(--color-primary, #3b82f6);
background: var(--neon-red);
color: #000000;
border-color: var(--neon-red);
box-shadow: 0 0 15px rgba(255, 0, 51, 0.4);
}
`],
template: `
@@ -51,7 +59,10 @@ const TAB_LABELS: Readonly<Record<ScoreboardTab, string>> = {
[class.active]="t.id === active"
[attr.data-testid]="'scoreboard-tab-' + t.id"
(click)="select.emit(t.id)"
>{{ t.label }}</button>
>
<span>{{ t.icon }}</span>
<span>{{ t.label }}</span>
</button>
</nav>
`,
})
@@ -59,5 +70,9 @@ 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] }));
readonly tabs: TabSpec[] = SCOREBOARD_TABS.map((id) => ({
id,
label: TAB_SPECS[id].label,
icon: TAB_SPECS[id].icon,
}));
}