Compare commits

...

10 Commits

Author SHA1 Message Date
bisco dd46d7aabb merge: apply filters only on refresh 2026-06-03 23:24:18 +02:00
bisco f8de0e644a fix: apply filters only on refresh 2026-06-03 23:24:03 +02:00
bisco 2c7ec7383b merge: allow returning from player detail to results 2026-06-03 23:15:48 +02:00
bisco f87f62f111 fix: allow returning from player detail to results 2026-06-03 23:15:30 +02:00
bisco f44a07f231 merge: use select control for role filter 2026-06-03 23:10:17 +02:00
bisco 8909be9694 fix: use select control for role filter 2026-06-03 23:10:00 +02:00
bisco 03b8762835 merge: remove scouting quick filter bar 2026-06-03 23:05:33 +02:00
bisco 5cc1076225 fix: remove scouting quick filter bar 2026-06-03 23:05:16 +02:00
bisco f2934d7924 merge: fix scouting dashboard controls 2026-06-03 22:56:33 +02:00
bisco 1b7b4259f4 fix: make scouting filters immediate and loading state precise 2026-06-03 22:56:13 +02:00
5 changed files with 147 additions and 118 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ The initial schema stores:
- per-season averages, totals, and advanced metrics; - per-season averages, totals, and advanced metrics;
- per-game logs for best and worst performance views. - per-game logs for best and worst performance views.
The Angular dashboard applies live filter updates with a short debounce, quick position/league chips, local stat sorting, summary metrics, and a persistent profile panel on desktop. The Angular dashboard applies filters only when the user refreshes the result set, then supports local stat sorting, summary metrics, and an open/close profile panel on desktop.
## External Integrations ## External Integrations
+27 -27
View File
@@ -126,23 +126,12 @@ button {
background: #e7eeeb; background: #e7eeeb;
} }
.quickbar { .compact {
display: grid; min-height: 34px;
grid-template-columns: auto minmax(0, 1fr) auto; padding: 0 10px;
gap: 10px; white-space: nowrap;
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 { .sort {
min-height: 30px; min-height: 30px;
padding: 0 10px; padding: 0 10px;
@@ -152,20 +141,12 @@ button {
white-space: nowrap; white-space: nowrap;
} }
.chip-group button.active,
.sort.active { .sort.active {
color: white; color: white;
background: var(--accent); background: var(--accent);
border-color: var(--accent); border-color: var(--accent);
} }
.filter-count {
color: var(--muted);
font-size: 0.8rem;
font-weight: 800;
white-space: nowrap;
}
.alert { .alert {
margin: 10px 0 0; margin: 10px 0 0;
padding: 12px 14px; padding: 12px 14px;
@@ -177,12 +158,16 @@ button {
.workspace { .workspace {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 390px; grid-template-columns: minmax(0, 1fr);
gap: 16px; gap: 16px;
margin-top: 8px; margin-top: 14px;
align-items: start; align-items: start;
} }
.workspace.detail-open {
grid-template-columns: minmax(0, 1fr) 390px;
}
.table-wrap, .table-wrap,
.detail { .detail {
background: var(--panel); background: var(--panel);
@@ -254,6 +239,13 @@ td small {
top: 14px; top: 14px;
} }
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.role { .role {
margin: 6px 0 0; margin: 6px 0 0;
color: var(--muted); color: var(--muted);
@@ -336,6 +328,10 @@ dd {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.workspace.detail-open {
grid-template-columns: 1fr;
}
.table-wrap { .table-wrap {
max-height: none; max-height: none;
} }
@@ -351,8 +347,7 @@ dd {
padding-top: 14px; padding-top: 14px;
} }
.topbar, .topbar {
.quickbar {
align-items: stretch; align-items: stretch;
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -390,6 +385,11 @@ dd {
h1 { h1 {
font-size: 2.6rem; font-size: 2.6rem;
} }
.detail-header {
display: grid;
grid-template-columns: 1fr;
}
} }
@media (max-width: 520px) { @media (max-width: 520px) {
+18 -43
View File
@@ -26,51 +26,47 @@
<input <input
type="search" type="search"
[(ngModel)]="filters.q" [(ngModel)]="filters.q"
(ngModelChange)="onFilterChange()"
placeholder="Name, role, team, nationality" placeholder="Name, role, team, nationality"
> >
</label> </label>
<label> <label>
Position Position
<select [(ngModel)]="filters.position" (ngModelChange)="onFilterChange()"> <select [(ngModel)]="filters.position">
<option value="">Any</option> <option value="">Any</option>
<option *ngFor="let position of positions" [value]="position">{{ position }}</option> <option *ngFor="let position of positions" [value]="position">{{ position }}</option>
</select> </select>
</label> </label>
<label> <label>
Role Role
<input <select [(ngModel)]="filters.role">
type="text" <option value="">Any</option>
[(ngModel)]="filters.role" <option *ngFor="let role of roles" [value]="role">{{ role }}</option>
(ngModelChange)="onFilterChange()" </select>
placeholder="3 and D, handler, rim runner"
>
</label> </label>
<label> <label>
League League
<select [(ngModel)]="filters.league" (ngModelChange)="onFilterChange()"> <select [(ngModel)]="filters.league">
<option value="">Any</option> <option value="">Any</option>
<option *ngFor="let league of leagues" [value]="league">{{ league }}</option> <option *ngFor="let league of leagues" [value]="league">{{ league }}</option>
</select> </select>
</label> </label>
<label> <label>
Min PPG Min PPG
<input type="number" [(ngModel)]="filters.minPoints" (ngModelChange)="onFilterChange()" min="0" step="0.1"> <input type="number" [(ngModel)]="filters.minPoints" min="0" step="0.1">
</label> </label>
<label> <label>
Min APG Min APG
<input type="number" [(ngModel)]="filters.minAssists" (ngModelChange)="onFilterChange()" min="0" step="0.1"> <input type="number" [(ngModel)]="filters.minAssists" min="0" step="0.1">
</label> </label>
<label> <label>
Min RPG Min RPG
<input type="number" [(ngModel)]="filters.minRebounds" (ngModelChange)="onFilterChange()" min="0" step="0.1"> <input type="number" [(ngModel)]="filters.minRebounds" min="0" step="0.1">
</label> </label>
<label> <label>
Min EFF Min EFF
<input <input
type="number" type="number"
[(ngModel)]="filters.minEfficiency" [(ngModel)]="filters.minEfficiency"
(ngModelChange)="onFilterChange()"
min="0" min="0"
step="0.1" step="0.1"
> >
@@ -81,33 +77,9 @@
</div> </div>
</section> </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> <p *ngIf="errorMessage" class="alert">{{ errorMessage }}</p>
<section class="workspace" aria-label="Scouting workspace"> <section class="workspace" [class.detail-open]="selectedPlayer" aria-label="Scouting workspace">
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
@@ -151,14 +123,17 @@
</tbody> </tbody>
</table> </table>
<div *ngIf="!loading && players.length === 0" class="empty">No players match the current filters.</div> <div *ngIf="!loading && players.length === 0" class="empty">No players match the current filters.</div>
<div *ngIf="loading" class="empty">Loading scouting board...</div> <div *ngIf="showLoadingPlaceholder" class="empty">Loading scouting board...</div>
</div> </div>
<aside class="detail" *ngIf="selectedPlayer"> <aside class="detail" *ngIf="selectedPlayer">
<div> <div class="detail-header">
<p class="eyebrow">Selected profile</p> <div>
<h2>{{ selectedPlayer.name }}</h2> <p class="eyebrow">Selected profile</p>
<p class="role">{{ selectedPlayer.position }} · {{ selectedPlayer.role || 'Role pending' }}</p> <h2>{{ selectedPlayer.name }}</h2>
<p class="role">{{ selectedPlayer.position }} · {{ selectedPlayer.role || 'Role pending' }}</p>
</div>
<button type="button" class="secondary compact" (click)="returnToResults()">Back to results</button>
</div> </div>
<div class="profile-strip"> <div class="profile-strip">
<span>{{ selectedPlayer.nationality || '-' }}</span> <span>{{ selectedPlayer.nationality || '-' }}</span>
+58 -4
View File
@@ -71,22 +71,61 @@ describe('AppComponent', () => {
assert.equal(component.resultCount, 2); assert.equal(component.resultCount, 2);
assert.equal(component.averagePoints, '14.00'); assert.equal(component.averagePoints, '14.00');
assert.equal(component.topEfficiencyPlayer?.name, 'Luca Marini'); assert.equal(component.topEfficiencyPlayer?.name, 'Luca Marini');
assert.equal(component.selectedPlayer, null);
}); });
it('reacts to filter changes without requiring the search button', () => { it('does not apply changed filters until refresh is requested', () => {
let calls = 0; let calls = 0;
let requestedLeague = '';
const api = { const api = {
searchPlayers: () => { searchPlayers: (filters: { league: string }) => {
calls += 1; calls += 1;
requestedLeague = filters.league;
return of({ count: 0, results: [] }); return of({ count: 0, results: [] });
}, },
} as unknown as PlayerApiService; } as unknown as PlayerApiService;
const component = new AppComponent(api); const component = new AppComponent(api);
component.onFilterChange(); component.filters.league = 'LBA';
component.flushPendingSearch(); component.filters.league = 'ABA';
assert.equal(calls, 0);
component.search();
assert.equal(calls, 1); assert.equal(calls, 1);
assert.equal(requestedLeague, 'ABA');
});
it('exposes role options and sends the selected role as a filter', () => {
let requestedRole = '';
const api = {
searchPlayers: (filters: { role: string }) => {
requestedRole = filters.role;
return of({ count: 0, results: [] });
},
} as unknown as PlayerApiService;
const component = new AppComponent(api);
assert.ok(component.roles.includes('3 and D wing'));
component.filters.role = '3 and D wing';
component.search();
assert.equal(requestedRole, '3 and D wing');
});
it('shows the loading placeholder only before results exist', () => {
const api = {
searchPlayers: () => of({ count: samplePlayers.length, results: samplePlayers }),
} as unknown as PlayerApiService;
const component = new AppComponent(api);
component.loading = true;
assert.equal(component.showLoadingPlaceholder, true);
component.players = samplePlayers;
assert.equal(component.showLoadingPlaceholder, false);
}); });
it('sorts the visible scouting board by selected stat', () => { it('sorts the visible scouting board by selected stat', () => {
@@ -100,4 +139,19 @@ describe('AppComponent', () => {
assert.equal(component.players[0].name, 'Mateo Santos'); assert.equal(component.players[0].name, 'Mateo Santos');
}); });
it('opens and closes a player profile without losing the filtered list', () => {
const api = {
searchPlayers: () => of({ count: samplePlayers.length, results: samplePlayers }),
} as unknown as PlayerApiService;
const component = new AppComponent(api);
component.search();
component.selectPlayer(samplePlayers[0]);
component.returnToResults();
assert.equal(component.selectedPlayer, null);
assert.equal(component.players.length, 2);
assert.equal(component.resultCount, 2);
});
}); });
+43 -43
View File
@@ -17,6 +17,34 @@ type SortKey = 'efficiency_rating' | 'points_per_game' | 'assists_per_game' | 'r
export class AppComponent { export class AppComponent {
readonly positions = ['PG', 'SG', 'SF', 'PF', 'C']; readonly positions = ['PG', 'SG', 'SF', 'PF', 'C'];
readonly leagues = ['LBA', 'ACB', 'ABA', 'BBL', 'BSL', 'LNB', 'ISBL', 'NBL', 'NZNBL']; readonly leagues = ['LBA', 'ACB', 'ABA', 'BBL', 'BSL', 'LNB', 'ISBL', 'NBL', 'NZNBL'];
readonly roles = [
'3 and D wing',
'Change-of-pace guard',
'Connector wing',
'Defensive playmaker',
'Drive and kick guard',
'Face-up forward',
'Glass cleaner',
'Low-post finisher',
'Movement shooter',
'Off-screen scorer',
'Paint anchor',
'Paint touch guard',
'Pick and roll creator',
'Pressure guard',
'Primary ball handler',
'Pull-up shooter',
'Rim protector',
'Roll man',
'Secondary creator',
'Short-roll passer',
'Slashing wing',
'Stretch four',
'Switch defender',
'Tempo guard',
'Transition wing',
'Vertical spacer',
];
readonly sortOptions: Array<{ key: SortKey; label: string }> = [ readonly sortOptions: Array<{ key: SortKey; label: string }> = [
{ key: 'efficiency_rating', label: 'EFF' }, { key: 'efficiency_rating', label: 'EFF' },
{ key: 'points_per_game', label: 'PPG' }, { key: 'points_per_game', label: 'PPG' },
@@ -41,7 +69,6 @@ export class AppComponent {
loading = false; loading = false;
errorMessage = ''; errorMessage = '';
activeSort: SortKey = 'efficiency_rating'; activeSort: SortKey = 'efficiency_rating';
private pendingSearch: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly playerApi: PlayerApiService) {} constructor(private readonly playerApi: PlayerApiService) {}
@@ -50,15 +77,15 @@ export class AppComponent {
} }
search(): void { search(): void {
this.cancelPendingSearch();
this.loading = true; this.loading = true;
this.errorMessage = ''; this.errorMessage = '';
this.playerApi.searchPlayers(this.filters).subscribe({ this.playerApi.searchPlayers(this.filters).subscribe({
next: (response) => { next: (response) => {
this.players = this.sortPlayers(response.results); const players = this.sortPlayers(response.results);
this.players = players;
this.resultCount = response.count; this.resultCount = response.count;
this.selectedPlayer = this.players[0] ?? null; this.selectedPlayer = this.matchSelectedPlayer(players);
this.loading = false; this.loading = false;
}, },
error: () => { error: () => {
@@ -68,19 +95,6 @@ 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 { clearFilters(): void {
this.filters = { this.filters = {
q: '', q: '',
@@ -95,26 +109,20 @@ export class AppComponent {
this.search(); 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 { sortBy(key: SortKey): void {
this.activeSort = key; this.activeSort = key;
this.players = this.sortPlayers(this.players); this.players = this.sortPlayers(this.players);
this.selectedPlayer = this.players[0] ?? null; this.selectedPlayer = this.matchSelectedPlayer(this.players);
} }
selectPlayer(player: PlayerSummary): void { selectPlayer(player: PlayerSummary): void {
this.selectedPlayer = player; this.selectedPlayer = player;
} }
returnToResults(): void {
this.selectedPlayer = null;
}
statValue(player: PlayerSummary, key: keyof NonNullable<PlayerSummary['stats']>): string { statValue(player: PlayerSummary, key: keyof NonNullable<PlayerSummary['stats']>): string {
const value = player.stats?.[key]; const value = player.stats?.[key];
return value === undefined || value === null ? '-' : String(value); return value === undefined || value === null ? '-' : String(value);
@@ -140,17 +148,8 @@ export class AppComponent {
return (total / this.players.length).toFixed(2); return (total / this.players.length).toFixed(2);
} }
get activeFiltersCount(): number { get showLoadingPlaceholder(): boolean {
return [ return this.loading && this.players.length === 0;
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[] { private sortPlayers(players: PlayerSummary[]): PlayerSummary[] {
@@ -161,10 +160,11 @@ export class AppComponent {
return Number(player.stats?.[key] ?? 0); return Number(player.stats?.[key] ?? 0);
} }
private cancelPendingSearch(): void { private matchSelectedPlayer(players: PlayerSummary[]): PlayerSummary | null {
if (this.pendingSearch) { if (!this.selectedPlayer) {
clearTimeout(this.pendingSearch); return null;
this.pendingSearch = null;
} }
return players.find((player) => player.id === this.selectedPlayer?.id) ?? null;
} }
} }