feat: improve scouting dashboard and demo data

This commit is contained in:
bisco
2026-06-03 22:42:08 +02:00
parent 22f4a9159a
commit 7101900e19
10 changed files with 633 additions and 160 deletions
+146 -23
View File
@@ -1,15 +1,15 @@
.shell {
width: min(1500px, calc(100vw - 32px));
width: min(1560px, calc(100vw - 28px));
margin: 0 auto;
padding: 24px 0 32px;
padding: 18px 0 28px;
}
.topbar {
display: flex;
align-items: flex-end;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
margin-bottom: 14px;
}
.eyebrow {
@@ -28,7 +28,7 @@ h2 {
}
h1 {
font-size: clamp(2rem, 4vw, 4rem);
font-size: 3.4rem;
line-height: 0.95;
}
@@ -36,37 +36,47 @@ h2 {
font-size: 1.6rem;
}
.status {
.scoreboard {
display: grid;
grid-template-columns: repeat(3, minmax(112px, 1fr));
gap: 8px;
}
.scoreboard div {
min-width: 112px;
padding: 12px 16px;
padding: 12px 14px;
color: white;
background: var(--accent-strong);
background: linear-gradient(135deg, var(--accent-strong), #253b55);
border-radius: 8px;
text-align: right;
}
.status span {
.scoreboard span {
display: block;
font-size: 1.6rem;
font-weight: 800;
}
.status small {
.scoreboard small {
color: rgba(255, 255, 255, 0.75);
}
.filters {
display: grid;
grid-template-columns: minmax(220px, 1.7fr) repeat(7, minmax(110px, 1fr)) auto;
grid-template-columns: minmax(260px, 1.4fr) repeat(3, minmax(130px, 0.7fr)) repeat(4, minmax(92px, 0.5fr)) auto;
gap: 10px;
align-items: end;
padding: 14px;
padding: 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.search-field {
min-width: 240px;
}
label {
display: grid;
gap: 6px;
@@ -116,8 +126,48 @@ button {
background: #e7eeeb;
}
.quickbar {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
margin-top: 10px;
padding: 10px 0;
}
.chip-group {
display: flex;
gap: 6px;
overflow-x: auto;
scrollbar-width: thin;
}
.chip-group button,
.sort {
min-height: 30px;
padding: 0 10px;
color: var(--accent-strong);
background: #e8efec;
border: 1px solid #cad8d2;
white-space: nowrap;
}
.chip-group button.active,
.sort.active {
color: white;
background: var(--accent);
border-color: var(--accent);
}
.filter-count {
color: var(--muted);
font-size: 0.8rem;
font-weight: 800;
white-space: nowrap;
}
.alert {
margin: 14px 0 0;
margin: 10px 0 0;
padding: 12px 14px;
color: #7a1d17;
background: #fff1ee;
@@ -127,21 +177,25 @@ button {
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
grid-template-columns: minmax(0, 1fr) 390px;
gap: 16px;
margin-top: 16px;
margin-top: 8px;
align-items: start;
}
.table-wrap,
.detail {
overflow: hidden;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.table-wrap {
max-height: calc(100vh - 238px);
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
@@ -150,13 +204,16 @@ table {
th,
td {
padding: 13px 12px;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: middle;
}
th {
position: sticky;
top: 0;
z-index: 1;
color: var(--muted);
background: #f8faf7;
font-size: 0.72rem;
@@ -193,6 +250,8 @@ td small {
display: grid;
gap: 18px;
padding: 18px;
position: sticky;
top: 14px;
}
.role {
@@ -200,14 +259,37 @@ td small {
color: var(--muted);
}
.profile-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.profile-strip span {
padding: 6px 10px;
color: var(--accent-strong);
background: #e8efec;
border: 1px solid #cad8d2;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 800;
}
.bio-grid,
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin: 0;
}
.bio-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.bio-grid div,
.metric-grid div {
min-height: 82px;
@@ -234,7 +316,7 @@ dd {
display: block;
margin-top: 8px;
color: var(--accent-strong);
font-size: 1.5rem;
font-size: 1.45rem;
}
@media (max-width: 1200px) {
@@ -242,13 +324,25 @@ dd {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.search-field {
grid-column: span 2;
}
.actions {
grid-column: span 4;
grid-column: span 2;
}
.workspace {
grid-template-columns: 1fr;
}
.table-wrap {
max-height: none;
}
.detail {
position: static;
}
}
@media (max-width: 760px) {
@@ -257,12 +351,19 @@ dd {
padding-top: 14px;
}
.topbar {
.topbar,
.quickbar {
align-items: stretch;
flex-direction: column;
display: grid;
grid-template-columns: 1fr;
}
.status {
.scoreboard {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.scoreboard div {
min-width: 0;
text-align: left;
}
@@ -270,6 +371,10 @@ dd {
grid-template-columns: 1fr 1fr;
}
.search-field {
grid-column: span 2;
}
.actions {
grid-column: span 2;
}
@@ -281,4 +386,22 @@ dd {
table {
min-width: 820px;
}
h1 {
font-size: 2.6rem;
}
}
@media (max-width: 520px) {
.scoreboard,
.filters,
.bio-grid,
.metric-grid {
grid-template-columns: 1fr;
}
.search-field,
.actions {
grid-column: auto;
}
}
+92 -19
View File
@@ -4,55 +4,107 @@
<p class="eyebrow">Private basketball scouting</p>
<h1>HoopScout</h1>
</div>
<div class="status">
<span>{{ resultCount }}</span>
<small>matches</small>
<div class="scoreboard" aria-label="Scouting summary">
<div>
<span>{{ resultCount }}</span>
<small>matches</small>
</div>
<div>
<span>{{ averagePoints }}</span>
<small>avg PPG</small>
</div>
<div>
<span>{{ averageEfficiency }}</span>
<small>avg EFF</small>
</div>
</div>
</header>
<section class="filters" aria-label="Player filters">
<label>
<label class="search-field">
Search
<input type="search" [(ngModel)]="filters.q" placeholder="Name, role, team, nationality">
<input
type="search"
[(ngModel)]="filters.q"
(ngModelChange)="onFilterChange()"
placeholder="Name, role, team, nationality"
>
</label>
<label>
Position
<select [(ngModel)]="filters.position">
<option *ngFor="let position of positions" [value]="position">{{ position || 'Any' }}</option>
<select [(ngModel)]="filters.position" (ngModelChange)="onFilterChange()">
<option value="">Any</option>
<option *ngFor="let position of positions" [value]="position">{{ position }}</option>
</select>
</label>
<label>
Role
<input type="text" [(ngModel)]="filters.role" placeholder="3 and D, handler, rim runner">
<input
type="text"
[(ngModel)]="filters.role"
(ngModelChange)="onFilterChange()"
placeholder="3 and D, handler, rim runner"
>
</label>
<label>
League
<select [(ngModel)]="filters.league">
<option *ngFor="let league of leagues" [value]="league">{{ league || 'Any' }}</option>
<select [(ngModel)]="filters.league" (ngModelChange)="onFilterChange()">
<option value="">Any</option>
<option *ngFor="let league of leagues" [value]="league">{{ league }}</option>
</select>
</label>
<label>
Min PPG
<input type="number" [(ngModel)]="filters.minPoints" min="0" step="0.1">
<input type="number" [(ngModel)]="filters.minPoints" (ngModelChange)="onFilterChange()" min="0" step="0.1">
</label>
<label>
Min APG
<input type="number" [(ngModel)]="filters.minAssists" min="0" step="0.1">
<input type="number" [(ngModel)]="filters.minAssists" (ngModelChange)="onFilterChange()" min="0" step="0.1">
</label>
<label>
Min RPG
<input type="number" [(ngModel)]="filters.minRebounds" min="0" step="0.1">
<input type="number" [(ngModel)]="filters.minRebounds" (ngModelChange)="onFilterChange()" min="0" step="0.1">
</label>
<label>
Min EFF
<input type="number" [(ngModel)]="filters.minEfficiency" min="0" step="0.1">
<input
type="number"
[(ngModel)]="filters.minEfficiency"
(ngModelChange)="onFilterChange()"
min="0"
step="0.1"
>
</label>
<div class="actions">
<button type="button" class="primary" (click)="search()">Search</button>
<button type="button" class="primary" (click)="search()">Refresh</button>
<button type="button" class="secondary" (click)="clearFilters()">Reset</button>
</div>
</section>
<section class="quickbar" aria-label="Quick filters">
<div class="chip-group">
<button
*ngFor="let position of positions"
type="button"
[class.active]="filters.position === position"
(click)="togglePosition(position)"
>
{{ position }}
</button>
</div>
<div class="chip-group league-chips">
<button
*ngFor="let league of leagues"
type="button"
[class.active]="filters.league === league"
(click)="toggleLeague(league)"
>
{{ league }}
</button>
</div>
<span class="filter-count">{{ activeFiltersCount }} active</span>
</section>
<p *ngIf="errorMessage" class="alert">{{ errorMessage }}</p>
<section class="workspace" aria-label="Scouting workspace">
@@ -64,10 +116,18 @@
<th>Pos</th>
<th>League</th>
<th>Team</th>
<th>PPG</th>
<th>APG</th>
<th>RPG</th>
<th>EFF</th>
<th>
<button type="button" class="sort" [class.active]="activeSort === 'points_per_game'" (click)="sortBy('points_per_game')">PPG</button>
</th>
<th>
<button type="button" class="sort" [class.active]="activeSort === 'assists_per_game'" (click)="sortBy('assists_per_game')">APG</button>
</th>
<th>
<button type="button" class="sort" [class.active]="activeSort === 'rebounds_per_game'" (click)="sortBy('rebounds_per_game')">RPG</button>
</th>
<th>
<button type="button" class="sort" [class.active]="activeSort === 'efficiency_rating'" (click)="sortBy('efficiency_rating')">EFF</button>
</th>
</tr>
</thead>
<tbody>
@@ -100,6 +160,11 @@
<h2>{{ selectedPlayer.name }}</h2>
<p class="role">{{ selectedPlayer.position }} · {{ selectedPlayer.role || 'Role pending' }}</p>
</div>
<div class="profile-strip">
<span>{{ selectedPlayer.nationality || '-' }}</span>
<span>{{ selectedPlayer.height_cm || '-' }} cm</span>
<span>{{ selectedPlayer.weight_kg || '-' }} kg</span>
</div>
<dl class="bio-grid">
<div>
<dt>League</dt>
@@ -123,6 +188,14 @@
<span>PPG</span>
<strong>{{ statValue(selectedPlayer, 'points_per_game') }}</strong>
</div>
<div>
<span>APG</span>
<strong>{{ statValue(selectedPlayer, 'assists_per_game') }}</strong>
</div>
<div>
<span>RPG</span>
<strong>{{ statValue(selectedPlayer, 'rebounds_per_game') }}</strong>
</div>
<div>
<span>TS%</span>
<strong>{{ statValue(selectedPlayer, 'true_shooting_percentage') }}</strong>
+81 -27
View File
@@ -7,43 +7,97 @@ import { AppComponent } from './app.component';
import { PlayerApiService } from './api/player-api.service';
describe('AppComponent', () => {
const samplePlayers = [
{
id: 1,
name: 'Luca Marini',
position: 'PG',
role: 'Primary ball handler',
birth_year: 2001,
height_cm: 190,
weight_kg: 86,
nationality: 'Italy',
league: { name: 'Lega Basket Serie A', code: 'LBA', region: 'Europe', country: 'Italy' },
team: { name: 'Milano', country: 'Italy' },
stats: {
games_played: 28,
minutes_per_game: '29.40',
points_per_game: '16.80',
assists_per_game: '6.20',
rebounds_per_game: '3.80',
efficiency_rating: '20.50',
true_shooting_percentage: '59.20',
usage_percentage: '25.00',
},
},
{
id: 2,
name: 'Mateo Santos',
position: 'SF',
role: '3 and D wing',
birth_year: 1999,
height_cm: 201,
weight_kg: 96,
nationality: 'Spain',
league: { name: 'Liga Endesa', code: 'ACB', region: 'Europe', country: 'Spain' },
team: { name: 'Madrid', country: 'Spain' },
stats: {
games_played: 25,
minutes_per_game: '24.10',
points_per_game: '11.20',
assists_per_game: '2.10',
rebounds_per_game: '5.50',
efficiency_rating: '13.70',
true_shooting_percentage: '55.80',
usage_percentage: '17.50',
},
},
];
it('loads players through the API service', () => {
const api = {
searchPlayers: () =>
of({
count: 1,
results: [
{
id: 1,
name: 'Luca Marini',
position: 'PG',
role: 'Primary ball handler',
birth_year: 2001,
height_cm: 190,
weight_kg: 86,
nationality: 'Italy',
league: { name: 'Lega Basket Serie A', code: 'LBA', region: 'Europe', country: 'Italy' },
team: { name: 'Milano', country: 'Italy' },
stats: {
games_played: 28,
minutes_per_game: '29.40',
points_per_game: '16.80',
assists_per_game: '6.20',
rebounds_per_game: '3.80',
efficiency_rating: '20.50',
true_shooting_percentage: '59.20',
usage_percentage: '25.00',
},
},
],
count: samplePlayers.length,
results: samplePlayers,
}),
} as unknown as PlayerApiService;
const component = new AppComponent(api);
component.search();
assert.equal(component.players.length, 1);
assert.equal(component.players.length, 2);
assert.equal(component.players[0].name, 'Luca Marini');
assert.equal(component.resultCount, 1);
assert.equal(component.resultCount, 2);
assert.equal(component.averagePoints, '14.00');
assert.equal(component.topEfficiencyPlayer?.name, 'Luca Marini');
});
it('reacts to filter changes without requiring the search button', () => {
let calls = 0;
const api = {
searchPlayers: () => {
calls += 1;
return of({ count: 0, results: [] });
},
} as unknown as PlayerApiService;
const component = new AppComponent(api);
component.onFilterChange();
component.flushPendingSearch();
assert.equal(calls, 1);
});
it('sorts the visible scouting board by selected stat', () => {
const api = {
searchPlayers: () => of({ count: samplePlayers.length, results: samplePlayers }),
} as unknown as PlayerApiService;
const component = new AppComponent(api);
component.search();
component.sortBy('rebounds_per_game');
assert.equal(component.players[0].name, 'Mateo Santos');
});
});
+93 -4
View File
@@ -3,7 +3,9 @@ import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { PlayerApiService } from './api/player-api.service';
import { PlayerFilters, PlayerSummary } from './models';
import { PlayerFilters, PlayerStats, PlayerSummary } from './models';
type SortKey = 'efficiency_rating' | 'points_per_game' | 'assists_per_game' | 'rebounds_per_game' | 'minutes_per_game';
@Component({
selector: 'app-root',
@@ -13,8 +15,15 @@ import { PlayerFilters, PlayerSummary } from './models';
styleUrl: './app.component.css',
})
export class AppComponent {
readonly positions = ['', 'PG', 'SG', 'SF', 'PF', 'C'];
readonly leagues = ['', 'LBA', 'ACB', 'ABA', 'BBL', 'BSL', 'LNB'];
readonly positions = ['PG', 'SG', 'SF', 'PF', 'C'];
readonly leagues = ['LBA', 'ACB', 'ABA', 'BBL', 'BSL', 'LNB', 'ISBL', 'NBL', 'NZNBL'];
readonly sortOptions: Array<{ key: SortKey; label: string }> = [
{ key: 'efficiency_rating', label: 'EFF' },
{ key: 'points_per_game', label: 'PPG' },
{ key: 'assists_per_game', label: 'APG' },
{ key: 'rebounds_per_game', label: 'RPG' },
{ key: 'minutes_per_game', label: 'MIN' },
];
filters: PlayerFilters = {
q: '',
@@ -31,6 +40,8 @@ export class AppComponent {
resultCount = 0;
loading = false;
errorMessage = '';
activeSort: SortKey = 'efficiency_rating';
private pendingSearch: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly playerApi: PlayerApiService) {}
@@ -39,12 +50,13 @@ export class AppComponent {
}
search(): void {
this.cancelPendingSearch();
this.loading = true;
this.errorMessage = '';
this.playerApi.searchPlayers(this.filters).subscribe({
next: (response) => {
this.players = response.results;
this.players = this.sortPlayers(response.results);
this.resultCount = response.count;
this.selectedPlayer = this.players[0] ?? null;
this.loading = false;
@@ -56,6 +68,19 @@ export class AppComponent {
});
}
onFilterChange(): void {
this.cancelPendingSearch();
this.pendingSearch = setTimeout(() => this.search(), 250);
}
flushPendingSearch(): void {
if (!this.pendingSearch) {
return;
}
this.cancelPendingSearch();
this.search();
}
clearFilters(): void {
this.filters = {
q: '',
@@ -70,6 +95,22 @@ export class AppComponent {
this.search();
}
togglePosition(position: string): void {
this.filters.position = this.filters.position === position ? '' : position;
this.onFilterChange();
}
toggleLeague(league: string): void {
this.filters.league = this.filters.league === league ? '' : league;
this.onFilterChange();
}
sortBy(key: SortKey): void {
this.activeSort = key;
this.players = this.sortPlayers(this.players);
this.selectedPlayer = this.players[0] ?? null;
}
selectPlayer(player: PlayerSummary): void {
this.selectedPlayer = player;
}
@@ -78,4 +119,52 @@ export class AppComponent {
const value = player.stats?.[key];
return value === undefined || value === null ? '-' : String(value);
}
get topEfficiencyPlayer(): PlayerSummary | null {
return [...this.players].sort((a, b) => this.numericStat(b, 'efficiency_rating') - this.numericStat(a, 'efficiency_rating'))[0] ?? null;
}
get averagePoints(): string {
if (this.players.length === 0) {
return '0.00';
}
const total = this.players.reduce((sum, player) => sum + this.numericStat(player, 'points_per_game'), 0);
return (total / this.players.length).toFixed(2);
}
get averageEfficiency(): string {
if (this.players.length === 0) {
return '0.00';
}
const total = this.players.reduce((sum, player) => sum + this.numericStat(player, 'efficiency_rating'), 0);
return (total / this.players.length).toFixed(2);
}
get activeFiltersCount(): number {
return [
this.filters.q,
this.filters.position,
this.filters.role,
this.filters.league,
this.filters.minPoints,
this.filters.minAssists,
this.filters.minRebounds,
this.filters.minEfficiency,
].filter((value) => value !== null && String(value).trim().length > 0).length;
}
private sortPlayers(players: PlayerSummary[]): PlayerSummary[] {
return [...players].sort((a, b) => this.numericStat(b, this.activeSort) - this.numericStat(a, this.activeSort));
}
private numericStat(player: PlayerSummary, key: keyof PlayerStats): number {
return Number(player.stats?.[key] ?? 0);
}
private cancelPendingSearch(): void {
if (this.pendingSearch) {
clearTimeout(this.pendingSearch);
this.pendingSearch = null;
}
}
}