merge: improve scouting dashboard and demo data

This commit is contained in:
bisco
2026-06-03 22:42:27 +02:00
10 changed files with 633 additions and 160 deletions
+1
View File
@@ -40,3 +40,4 @@ docker compose config
```
Demo data is synthetic. RealGM, Proballers, or other external sources must be integrated only through authorized APIs or documented, compliant import workflows.
The demo seed creates a broader scouting board across European leagues plus Australia and New Zealand so filters, sorting, and profile review can be exercised locally.
+1 -1
View File
@@ -84,7 +84,7 @@ REST_FRAMEWORK = {
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 25,
"PAGE_SIZE": 50,
}
CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:4200").split(",")
@@ -33,82 +33,164 @@ class Command(BaseCommand):
code="LNB",
defaults={"name": "LNB Elite", "region": "Europe", "country": "France"},
)[0],
"ISBL": League.objects.update_or_create(
code="ISBL",
defaults={"name": "Israeli Basketball Premier League", "region": "Europe", "country": "Israel"},
)[0],
"NBL": League.objects.update_or_create(
code="NBL",
defaults={"name": "National Basketball League", "region": "Oceania", "country": "Australia"},
)[0],
"NZNBL": League.objects.update_or_create(
code="NZNBL",
defaults={"name": "New Zealand NBL", "region": "Oceania", "country": "New Zealand"},
)[0],
}
demo_rows = [
{
"first_name": "Luca",
"last_name": "Marini",
"position": "PG",
"role": "Primary ball handler",
"birth_year": 2001,
"height_cm": 190,
"weight_kg": 86,
"nationality": "Italy",
"team": "Milano",
"league": "LBA",
"points": 16.8,
"assists": 6.2,
"rebounds": 3.8,
"efficiency": 20.5,
"ts": 59.2,
"usage": 25.0,
},
{
"first_name": "Mateo",
"last_name": "Santos",
"position": "SF",
"role": "3 and D wing",
"birth_year": 1999,
"height_cm": 201,
"weight_kg": 96,
"nationality": "Spain",
"team": "Madrid",
"league": "ACB",
"points": 11.2,
"assists": 2.1,
"rebounds": 5.5,
"efficiency": 13.7,
"ts": 55.8,
"usage": 17.5,
},
{
"first_name": "Jonas",
"last_name": "Keller",
"position": "C",
"role": "Roll man",
"birth_year": 2000,
"height_cm": 211,
"weight_kg": 109,
"nationality": "Germany",
"team": "Berlin",
"league": "BBL",
"points": 13.1,
"assists": 1.4,
"rebounds": 8.6,
"efficiency": 18.9,
"ts": 63.1,
"usage": 19.0,
},
(
"Luca", "Marini", "PG", "Primary ball handler", 2001, 190, 86, "Italy",
"Milano", "LBA", 16.8, 6.2, 3.8, 20.5, 59.2, 25.0,
),
(
"Davide", "Rossi", "SG", "Movement shooter", 2002, 195, 88, "Italy",
"Bologna", "LBA", 14.6, 2.4, 3.1, 15.8, 61.0, 21.4,
),
(
"Nikola", "Petrovic", "PF", "Stretch four", 1998, 206, 103, "Serbia",
"Trieste", "LBA", 12.9, 2.7, 6.8, 17.2, 58.4, 18.8,
),
(
"Mateo", "Santos", "SF", "3 and D wing", 1999, 201, 96, "Spain",
"Madrid", "ACB", 11.2, 2.1, 5.5, 13.7, 55.8, 17.5,
),
(
"Iker", "Varela", "PG", "Tempo guard", 2000, 188, 82, "Spain",
"Valencia", "ACB", 13.8, 5.9, 2.9, 18.1, 57.3, 23.7,
),
(
"Moussa", "Diagne", "C", "Rim protector", 1997, 213, 111, "Senegal",
"Malaga", "ACB", 9.7, 1.1, 8.9, 16.4, 64.8, 15.2,
),
(
"Marko", "Ilic", "PG", "Pick and roll creator", 2001, 192, 87, "Serbia",
"Belgrade", "ABA", 15.1, 6.8, 3.3, 19.6, 56.6, 26.1,
),
(
"Luka", "Horvat", "SF", "Slashing wing", 1999, 202, 94, "Croatia",
"Zadar", "ABA", 13.4, 2.8, 5.9, 16.0, 55.1, 20.5,
),
(
"Amar", "Kovac", "C", "Low-post finisher", 1998, 210, 114, "Bosnia",
"Ljubljana", "ABA", 11.8, 1.6, 7.7, 15.5, 60.9, 18.0,
),
(
"Jonas", "Keller", "C", "Roll man", 2000, 211, 109, "Germany",
"Berlin", "BBL", 13.1, 1.4, 8.6, 18.9, 63.1, 19.0,
),
(
"Tobias", "Weber", "SG", "Secondary creator", 2002, 196, 91, "Germany",
"Munich", "BBL", 15.9, 3.7, 4.2, 17.8, 58.0, 22.9,
),
(
"Leon", "Schmidt", "PF", "Short-roll passer", 1999, 205, 101, "Germany",
"Ulm", "BBL", 10.6, 3.2, 6.4, 14.9, 56.7, 16.8,
),
(
"Emir", "Yilmaz", "PG", "Pressure guard", 2001, 189, 84, "Turkey",
"Istanbul", "BSL", 17.4, 5.5, 3.0, 20.1, 57.8, 27.2,
),
(
"Can", "Demir", "SF", "Transition wing", 2000, 200, 95, "Turkey",
"Ankara", "BSL", 12.7, 2.5, 5.1, 14.4, 54.6, 19.4,
),
(
"Kerem", "Arslan", "C", "Paint anchor", 1998, 212, 116, "Turkey",
"Izmir", "BSL", 10.2, 1.2, 9.3, 17.0, 62.3, 14.8,
),
(
"Noam", "Levi", "SG", "Pull-up shooter", 2002, 194, 88, "Israel",
"Tel Aviv", "ISBL", 16.1, 3.0, 3.5, 16.9, 59.5, 24.1,
),
(
"Amit", "Cohen", "PG", "Drive and kick guard", 2001, 187, 81, "Israel",
"Jerusalem", "ISBL", 14.2, 6.4, 2.7, 18.7, 56.9, 25.5,
),
(
"Eitan", "Mizrahi", "PF", "Switch defender", 1999, 204, 100, "Israel",
"Holon", "ISBL", 9.8, 2.0, 7.1, 13.9, 54.2, 15.7,
),
(
"Theo", "Moreau", "PG", "Change-of-pace guard", 2003, 186, 80, "France",
"Paris", "LNB", 12.5, 5.8, 2.5, 16.8, 55.5, 22.2,
),
(
"Bastien", "Girard", "SF", "Connector wing", 2000, 199, 92, "France",
"Monaco", "LNB", 10.9, 3.6, 4.8, 14.6, 57.1, 16.3,
),
(
"Yanis", "Traore", "C", "Vertical spacer", 2001, 214, 112, "France",
"Lyon", "LNB", 11.4, 1.0, 8.1, 16.7, 65.0, 15.0,
),
(
"Jayden", "Mills", "SG", "Off-screen scorer", 2000, 197, 92, "Australia",
"Sydney", "NBL", 18.2, 2.9, 4.0, 19.4, 60.1, 26.4,
),
(
"Cooper", "Reed", "PF", "Face-up forward", 1999, 206, 102, "Australia",
"Melbourne", "NBL", 13.6, 2.4, 7.3, 17.5, 58.6, 20.0,
),
(
"Hemi", "Walker", "PG", "Paint touch guard", 2002, 191, 85, "New Zealand",
"Auckland", "NZNBL", 15.7, 7.1, 3.9, 21.2, 57.5, 27.0,
),
(
"Tane", "Rangi", "SF", "Defensive playmaker", 2001, 203, 98, "New Zealand",
"Wellington", "NZNBL", 11.6, 3.4, 6.5, 15.9, 55.4, 18.7,
),
(
"Finn", "McKenzie", "C", "Glass cleaner", 1998, 211, 113, "New Zealand",
"Canterbury", "NZNBL", 9.4, 1.3, 10.2, 16.1, 61.8, 13.9,
),
]
for row in demo_rows:
league = leagues[row["league"]]
(
first_name,
last_name,
position,
role,
birth_year,
height_cm,
weight_kg,
nationality,
team_name,
league_code,
points,
assists,
rebounds,
efficiency,
true_shooting,
usage,
) = row
games_played = 28
minutes = round(18 + usage * 0.42, 2)
league = leagues[league_code]
team, _ = Team.objects.update_or_create(
name=row["team"],
name=team_name,
league=league,
defaults={"country": league.country},
)
player, _ = Player.objects.update_or_create(
first_name=row["first_name"],
last_name=row["last_name"],
birth_year=row["birth_year"],
nationality=row["nationality"],
first_name=first_name,
last_name=last_name,
birth_year=birth_year,
nationality=nationality,
defaults={
"position": row["position"],
"role": row["role"],
"height_cm": row["height_cm"],
"weight_kg": row["weight_kg"],
"position": position,
"role": role,
"height_cm": height_cm,
"weight_kg": weight_kg,
"current_team": team,
"external_source": "synthetic",
},
@@ -119,23 +201,23 @@ class Command(BaseCommand):
league=league,
season=season,
defaults={
"games_played": 28,
"minutes_per_game": 27.5,
"points_per_game": row["points"],
"assists_per_game": row["assists"],
"rebounds_per_game": row["rebounds"],
"steals_per_game": 1.0,
"blocks_per_game": 0.4,
"turnovers_per_game": 1.8,
"field_goal_percentage": 48.0,
"three_point_percentage": 37.5,
"free_throw_percentage": 81.0,
"efficiency_rating": row["efficiency"],
"true_shooting_percentage": row["ts"],
"usage_percentage": row["usage"],
"total_points": int(row["points"] * 28),
"total_assists": int(row["assists"] * 28),
"total_rebounds": int(row["rebounds"] * 28),
"games_played": games_played,
"minutes_per_game": minutes,
"points_per_game": points,
"assists_per_game": assists,
"rebounds_per_game": rebounds,
"steals_per_game": 0.7 + (assists / 10),
"blocks_per_game": 0.2 + (rebounds / 18),
"turnovers_per_game": 1.0 + (usage / 20),
"field_goal_percentage": 42.5 + (true_shooting / 5),
"three_point_percentage": 28.0 + (points / 2.3),
"free_throw_percentage": 70.0 + (usage / 2),
"efficiency_rating": efficiency,
"true_shooting_percentage": true_shooting,
"usage_percentage": usage,
"total_points": int(points * games_played),
"total_assists": int(assists * games_played),
"total_rebounds": int(rebounds * games_played),
},
)
PlayerGameLog.objects.update_or_create(
@@ -146,10 +228,24 @@ class Command(BaseCommand):
game_date="2026-01-10",
opponent="Top domestic opponent",
defaults={
"points": int(row["points"] + 10),
"assists": int(row["assists"] + 3),
"rebounds": int(row["rebounds"] + 2),
"efficiency_rating": row["efficiency"] + 10,
"points": int(points + 10),
"assists": int(assists + 3),
"rebounds": int(rebounds + 2),
"efficiency_rating": efficiency + 10,
},
)
PlayerGameLog.objects.update_or_create(
player=player,
team=team,
league=league,
season=season,
game_date="2026-01-17",
opponent="Physical road opponent",
defaults={
"points": max(0, int(points - 7)),
"assists": max(0, int(assists - 2)),
"rebounds": max(0, int(rebounds - 3)),
"efficiency_rating": max(0, efficiency - 9),
},
)
@@ -0,0 +1,33 @@
import pytest
from django.core.management import call_command
from scouting.models import League, Player, PlayerSeasonStat
@pytest.mark.django_db
def test_seed_demo_data_creates_a_useful_scouting_board():
call_command("seed_demo_data")
assert Player.objects.count() >= 24
assert PlayerSeasonStat.objects.count() >= 24
assert set(League.objects.values_list("code", flat=True)) >= {
"LBA",
"ACB",
"ABA",
"BBL",
"BSL",
"LNB",
"ISBL",
"NBL",
"NZNBL",
}
@pytest.mark.django_db
def test_seed_demo_data_is_idempotent():
call_command("seed_demo_data")
first_count = Player.objects.count()
call_command("seed_demo_data")
assert Player.objects.count() == first_count
+3 -1
View File
@@ -21,9 +21,11 @@ The initial schema stores:
- per-season averages, totals, and advanced metrics;
- 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.
## External Integrations
No automated external ingestion is included in the MVP. Demo data is synthetic. RealGM, Proballers, or similar data providers require a later authorized API/import decision before real data is collected.
No automated external ingestion is included in the MVP. Demo data is synthetic and intentionally broad enough for UI testing. RealGM, Proballers, or similar data providers require a later authorized API/import decision before real data is collected.
## Relevant ADRs
+2
View File
@@ -14,6 +14,8 @@ Seed demo data:
docker compose run --rm backend python manage.py seed_demo_data
```
The seed command is idempotent and refreshes the synthetic scouting board.
Create an admin user:
```bash
+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;
}
}
}