feat: bootstrap HoopScout scouting app

This commit is contained in:
bisco
2026-06-03 21:37:15 +02:00
parent c4b1b6ee15
commit cc188468bc
52 changed files with 14505 additions and 126 deletions
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { buildPlayerSearchParams } from './player-api.service';
describe('buildPlayerSearchParams', () => {
it('keeps only populated filters and maps stat ranges to API lookups', () => {
const params = buildPlayerSearchParams({
q: 'luca',
position: 'PG',
role: '',
league: 'LBA',
minPoints: 15,
minAssists: 5,
minRebounds: null,
minEfficiency: 18,
});
assert.equal(
params.toString(),
'q=luca&position=PG&league=LBA&points_per_game__gte=15&assists_per_game__gte=5&efficiency_rating__gte=18',
);
});
});
@@ -0,0 +1,51 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { PlayerFilters, PlayerSearchResponse } from '../models';
const API_BASE_URL = '/api';
export function buildPlayerSearchParams(filters: PlayerFilters): HttpParams {
let params = new HttpParams();
const textFilters: Array<[string, string]> = [
['q', filters.q],
['position', filters.position],
['role', filters.role],
['league', filters.league],
];
for (const [key, value] of textFilters) {
if (value.trim().length > 0) {
params = params.set(key, value.trim());
}
}
const statFilters: Array<[string, number | null]> = [
['points_per_game__gte', filters.minPoints],
['assists_per_game__gte', filters.minAssists],
['rebounds_per_game__gte', filters.minRebounds],
['efficiency_rating__gte', filters.minEfficiency],
];
for (const [key, value] of statFilters) {
if (value !== null && Number.isFinite(value)) {
params = params.set(key, String(value));
}
}
return params;
}
@Injectable({ providedIn: 'root' })
export class PlayerApiService {
constructor(private readonly http: HttpClient) {}
searchPlayers(filters: PlayerFilters): Observable<PlayerSearchResponse> {
return this.http.get<PlayerSearchResponse>(`${API_BASE_URL}/players/`, {
params: buildPlayerSearchParams(filters),
withCredentials: true,
});
}
}
+284
View File
@@ -0,0 +1,284 @@
.shell {
width: min(1500px, calc(100vw - 32px));
margin: 0 auto;
padding: 24px 0 32px;
}
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
}
.eyebrow {
margin: 0 0 4px;
color: var(--accent);
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: clamp(2rem, 4vw, 4rem);
line-height: 0.95;
}
h2 {
font-size: 1.6rem;
}
.status {
min-width: 112px;
padding: 12px 16px;
color: white;
background: var(--accent-strong);
border-radius: 8px;
text-align: right;
}
.status span {
display: block;
font-size: 1.6rem;
font-weight: 800;
}
.status small {
color: rgba(255, 255, 255, 0.75);
}
.filters {
display: grid;
grid-template-columns: minmax(220px, 1.7fr) repeat(7, minmax(110px, 1fr)) auto;
gap: 10px;
align-items: end;
padding: 14px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 0.75rem;
font-weight: 700;
}
input,
select {
width: 100%;
min-height: 40px;
padding: 0 10px;
color: var(--ink);
background: #fbfcfa;
border: 1px solid var(--line);
border-radius: 6px;
}
input:focus,
select:focus {
border-color: var(--accent);
outline: 2px solid rgba(23, 107, 100, 0.16);
}
.actions {
display: flex;
gap: 8px;
}
button {
min-height: 40px;
padding: 0 14px;
border: 0;
border-radius: 6px;
cursor: pointer;
font-weight: 800;
}
.primary {
color: white;
background: var(--accent);
}
.secondary {
color: var(--accent-strong);
background: #e7eeeb;
}
.alert {
margin: 14px 0 0;
padding: 12px 14px;
color: #7a1d17;
background: #fff1ee;
border: 1px solid #f2bbb2;
border-radius: 8px;
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 16px;
margin-top: 16px;
align-items: start;
}
.table-wrap,
.detail {
overflow: hidden;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
padding: 13px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: middle;
}
th {
color: var(--muted);
background: #f8faf7;
font-size: 0.72rem;
text-transform: uppercase;
}
tbody tr {
cursor: pointer;
}
tbody tr:hover,
tbody tr.selected {
background: #eef6f3;
}
td strong,
td small {
display: block;
}
td small {
margin-top: 3px;
color: var(--muted);
font-size: 0.75rem;
}
.empty {
padding: 28px;
color: var(--muted);
text-align: center;
}
.detail {
display: grid;
gap: 18px;
padding: 18px;
}
.role {
margin: 6px 0 0;
color: var(--muted);
}
.bio-grid,
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin: 0;
}
.bio-grid div,
.metric-grid div {
min-height: 82px;
padding: 12px;
background: #f8faf7;
border: 1px solid var(--line);
border-radius: 8px;
}
dt,
.metric-grid span {
color: var(--muted);
font-size: 0.74rem;
font-weight: 800;
text-transform: uppercase;
}
dd {
margin: 6px 0 0;
font-weight: 700;
}
.metric-grid strong {
display: block;
margin-top: 8px;
color: var(--accent-strong);
font-size: 1.5rem;
}
@media (max-width: 1200px) {
.filters {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.actions {
grid-column: span 4;
}
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.shell {
width: min(100vw - 20px, 1500px);
padding-top: 14px;
}
.topbar {
align-items: stretch;
flex-direction: column;
}
.status {
text-align: left;
}
.filters {
grid-template-columns: 1fr 1fr;
}
.actions {
grid-column: span 2;
}
.table-wrap {
overflow-x: auto;
}
table {
min-width: 820px;
}
}
+141
View File
@@ -0,0 +1,141 @@
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">Private basketball scouting</p>
<h1>HoopScout</h1>
</div>
<div class="status">
<span>{{ resultCount }}</span>
<small>matches</small>
</div>
</header>
<section class="filters" aria-label="Player filters">
<label>
Search
<input type="search" [(ngModel)]="filters.q" placeholder="Name, role, team, nationality">
</label>
<label>
Position
<select [(ngModel)]="filters.position">
<option *ngFor="let position of positions" [value]="position">{{ position || 'Any' }}</option>
</select>
</label>
<label>
Role
<input type="text" [(ngModel)]="filters.role" 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>
</label>
<label>
Min PPG
<input type="number" [(ngModel)]="filters.minPoints" min="0" step="0.1">
</label>
<label>
Min APG
<input type="number" [(ngModel)]="filters.minAssists" min="0" step="0.1">
</label>
<label>
Min RPG
<input type="number" [(ngModel)]="filters.minRebounds" min="0" step="0.1">
</label>
<label>
Min EFF
<input type="number" [(ngModel)]="filters.minEfficiency" min="0" step="0.1">
</label>
<div class="actions">
<button type="button" class="primary" (click)="search()">Search</button>
<button type="button" class="secondary" (click)="clearFilters()">Reset</button>
</div>
</section>
<p *ngIf="errorMessage" class="alert">{{ errorMessage }}</p>
<section class="workspace" aria-label="Scouting workspace">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Player</th>
<th>Pos</th>
<th>League</th>
<th>Team</th>
<th>PPG</th>
<th>APG</th>
<th>RPG</th>
<th>EFF</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let player of players"
[class.selected]="selectedPlayer?.id === player.id"
(click)="selectPlayer(player)"
>
<td>
<strong>{{ player.name }}</strong>
<small>{{ player.nationality || '-' }} · {{ player.height_cm || '-' }} cm · {{ player.weight_kg || '-' }} kg</small>
</td>
<td>{{ player.position }}</td>
<td>{{ player.league?.code || '-' }}</td>
<td>{{ player.team?.name || '-' }}</td>
<td>{{ statValue(player, 'points_per_game') }}</td>
<td>{{ statValue(player, 'assists_per_game') }}</td>
<td>{{ statValue(player, 'rebounds_per_game') }}</td>
<td>{{ statValue(player, 'efficiency_rating') }}</td>
</tr>
</tbody>
</table>
<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>
<aside class="detail" *ngIf="selectedPlayer">
<div>
<p class="eyebrow">Selected profile</p>
<h2>{{ selectedPlayer.name }}</h2>
<p class="role">{{ selectedPlayer.position }} · {{ selectedPlayer.role || 'Role pending' }}</p>
</div>
<dl class="bio-grid">
<div>
<dt>League</dt>
<dd>{{ selectedPlayer.league?.name || '-' }}</dd>
</div>
<div>
<dt>Team</dt>
<dd>{{ selectedPlayer.team?.name || '-' }}</dd>
</div>
<div>
<dt>Born</dt>
<dd>{{ selectedPlayer.birth_year || '-' }}</dd>
</div>
<div>
<dt>Size</dt>
<dd>{{ selectedPlayer.height_cm || '-' }} cm / {{ selectedPlayer.weight_kg || '-' }} kg</dd>
</div>
</dl>
<div class="metric-grid">
<div>
<span>PPG</span>
<strong>{{ statValue(selectedPlayer, 'points_per_game') }}</strong>
</div>
<div>
<span>TS%</span>
<strong>{{ statValue(selectedPlayer, 'true_shooting_percentage') }}</strong>
</div>
<div>
<span>USG%</span>
<strong>{{ statValue(selectedPlayer, 'usage_percentage') }}</strong>
</div>
<div>
<span>Games</span>
<strong>{{ statValue(selectedPlayer, 'games_played') }}</strong>
</div>
</div>
</aside>
</section>
</main>
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { of } from 'rxjs';
import { AppComponent } from './app.component';
import { PlayerApiService } from './api/player-api.service';
describe('AppComponent', () => {
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',
},
},
],
}),
} as unknown as PlayerApiService;
const component = new AppComponent(api);
component.search();
assert.equal(component.players.length, 1);
assert.equal(component.players[0].name, 'Luca Marini');
assert.equal(component.resultCount, 1);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { PlayerApiService } from './api/player-api.service';
import { PlayerFilters, PlayerSummary } from './models';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './app.component.html',
styleUrl: './app.component.css',
})
export class AppComponent {
readonly positions = ['', 'PG', 'SG', 'SF', 'PF', 'C'];
readonly leagues = ['', 'LBA', 'ACB', 'ABA', 'BBL', 'BSL', 'LNB'];
filters: PlayerFilters = {
q: '',
position: '',
role: '',
league: '',
minPoints: null,
minAssists: null,
minRebounds: null,
minEfficiency: null,
};
players: PlayerSummary[] = [];
selectedPlayer: PlayerSummary | null = null;
resultCount = 0;
loading = false;
errorMessage = '';
constructor(private readonly playerApi: PlayerApiService) {}
ngOnInit(): void {
this.search();
}
search(): void {
this.loading = true;
this.errorMessage = '';
this.playerApi.searchPlayers(this.filters).subscribe({
next: (response) => {
this.players = response.results;
this.resultCount = response.count;
this.selectedPlayer = this.players[0] ?? null;
this.loading = false;
},
error: () => {
this.errorMessage = 'Sign in is required or the scouting API is unavailable.';
this.loading = false;
},
});
}
clearFilters(): void {
this.filters = {
q: '',
position: '',
role: '',
league: '',
minPoints: null,
minAssists: null,
minRebounds: null,
minEfficiency: null,
};
this.search();
}
selectPlayer(player: PlayerSummary): void {
this.selectedPlayer = player;
}
statValue(player: PlayerSummary, key: keyof NonNullable<PlayerSummary['stats']>): string {
const value = player.stats?.[key];
return value === undefined || value === null ? '-' : String(value);
}
}
+52
View File
@@ -0,0 +1,52 @@
export interface League {
name: string;
code: string;
region: string;
country: string;
}
export interface Team {
name: string;
country: string;
}
export interface PlayerStats {
games_played: number;
minutes_per_game: string;
points_per_game: string;
assists_per_game: string;
rebounds_per_game: string;
efficiency_rating: string;
true_shooting_percentage: string;
usage_percentage: string;
}
export interface PlayerSummary {
id: number;
name: string;
position: string;
role: string;
birth_year: number | null;
height_cm: number | null;
weight_kg: number | null;
nationality: string;
league: League | null;
team: Team | null;
stats: PlayerStats | null;
}
export interface PlayerSearchResponse {
count: number;
results: PlayerSummary[];
}
export interface PlayerFilters {
q: string;
position: string;
role: string;
league: string;
minPoints: number | null;
minAssists: number | null;
minRebounds: number | null;
minEfficiency: number | null;
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>HoopScout</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<app-root></app-root>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
import { provideHttpClient, withXsrfConfiguration } from '@angular/common/http';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
provideAnimations(),
provideHttpClient(
withXsrfConfiguration({
cookieName: 'csrftoken',
headerName: 'X-CSRFToken',
}),
),
],
}).catch((error) => console.error(error));
+30
View File
@@ -0,0 +1,30 @@
:root {
color-scheme: light;
--bg: #f3f5f1;
--ink: #17211f;
--muted: #66706c;
--panel: #ffffff;
--line: #d9dfd9;
--accent: #176b64;
--accent-strong: #0d4d48;
--gold: #bd8b2f;
--red: #a4473f;
--shadow: 0 18px 50px rgba(25, 38, 35, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--ink);
background: var(--bg);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
select {
font: inherit;
}
+1
View File
@@ -0,0 +1 @@
import '@angular/compiler';