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
+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. 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", "rest_framework.permissions.IsAuthenticated",
], ],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "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(",") CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:4200").split(",")
@@ -33,82 +33,164 @@ class Command(BaseCommand):
code="LNB", code="LNB",
defaults={"name": "LNB Elite", "region": "Europe", "country": "France"}, defaults={"name": "LNB Elite", "region": "Europe", "country": "France"},
)[0], )[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 = [ demo_rows = [
{ (
"first_name": "Luca", "Luca", "Marini", "PG", "Primary ball handler", 2001, 190, 86, "Italy",
"last_name": "Marini", "Milano", "LBA", 16.8, 6.2, 3.8, 20.5, 59.2, 25.0,
"position": "PG", ),
"role": "Primary ball handler", (
"birth_year": 2001, "Davide", "Rossi", "SG", "Movement shooter", 2002, 195, 88, "Italy",
"height_cm": 190, "Bologna", "LBA", 14.6, 2.4, 3.1, 15.8, 61.0, 21.4,
"weight_kg": 86, ),
"nationality": "Italy", (
"team": "Milano", "Nikola", "Petrovic", "PF", "Stretch four", 1998, 206, 103, "Serbia",
"league": "LBA", "Trieste", "LBA", 12.9, 2.7, 6.8, 17.2, 58.4, 18.8,
"points": 16.8, ),
"assists": 6.2, (
"rebounds": 3.8, "Mateo", "Santos", "SF", "3 and D wing", 1999, 201, 96, "Spain",
"efficiency": 20.5, "Madrid", "ACB", 11.2, 2.1, 5.5, 13.7, 55.8, 17.5,
"ts": 59.2, ),
"usage": 25.0, (
}, "Iker", "Varela", "PG", "Tempo guard", 2000, 188, 82, "Spain",
{ "Valencia", "ACB", 13.8, 5.9, 2.9, 18.1, 57.3, 23.7,
"first_name": "Mateo", ),
"last_name": "Santos", (
"position": "SF", "Moussa", "Diagne", "C", "Rim protector", 1997, 213, 111, "Senegal",
"role": "3 and D wing", "Malaga", "ACB", 9.7, 1.1, 8.9, 16.4, 64.8, 15.2,
"birth_year": 1999, ),
"height_cm": 201, (
"weight_kg": 96, "Marko", "Ilic", "PG", "Pick and roll creator", 2001, 192, 87, "Serbia",
"nationality": "Spain", "Belgrade", "ABA", 15.1, 6.8, 3.3, 19.6, 56.6, 26.1,
"team": "Madrid", ),
"league": "ACB", (
"points": 11.2, "Luka", "Horvat", "SF", "Slashing wing", 1999, 202, 94, "Croatia",
"assists": 2.1, "Zadar", "ABA", 13.4, 2.8, 5.9, 16.0, 55.1, 20.5,
"rebounds": 5.5, ),
"efficiency": 13.7, (
"ts": 55.8, "Amar", "Kovac", "C", "Low-post finisher", 1998, 210, 114, "Bosnia",
"usage": 17.5, "Ljubljana", "ABA", 11.8, 1.6, 7.7, 15.5, 60.9, 18.0,
}, ),
{ (
"first_name": "Jonas", "Jonas", "Keller", "C", "Roll man", 2000, 211, 109, "Germany",
"last_name": "Keller", "Berlin", "BBL", 13.1, 1.4, 8.6, 18.9, 63.1, 19.0,
"position": "C", ),
"role": "Roll man", (
"birth_year": 2000, "Tobias", "Weber", "SG", "Secondary creator", 2002, 196, 91, "Germany",
"height_cm": 211, "Munich", "BBL", 15.9, 3.7, 4.2, 17.8, 58.0, 22.9,
"weight_kg": 109, ),
"nationality": "Germany", (
"team": "Berlin", "Leon", "Schmidt", "PF", "Short-roll passer", 1999, 205, 101, "Germany",
"league": "BBL", "Ulm", "BBL", 10.6, 3.2, 6.4, 14.9, 56.7, 16.8,
"points": 13.1, ),
"assists": 1.4, (
"rebounds": 8.6, "Emir", "Yilmaz", "PG", "Pressure guard", 2001, 189, 84, "Turkey",
"efficiency": 18.9, "Istanbul", "BSL", 17.4, 5.5, 3.0, 20.1, 57.8, 27.2,
"ts": 63.1, ),
"usage": 19.0, (
}, "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: 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( team, _ = Team.objects.update_or_create(
name=row["team"], name=team_name,
league=league, league=league,
defaults={"country": league.country}, defaults={"country": league.country},
) )
player, _ = Player.objects.update_or_create( player, _ = Player.objects.update_or_create(
first_name=row["first_name"], first_name=first_name,
last_name=row["last_name"], last_name=last_name,
birth_year=row["birth_year"], birth_year=birth_year,
nationality=row["nationality"], nationality=nationality,
defaults={ defaults={
"position": row["position"], "position": position,
"role": row["role"], "role": role,
"height_cm": row["height_cm"], "height_cm": height_cm,
"weight_kg": row["weight_kg"], "weight_kg": weight_kg,
"current_team": team, "current_team": team,
"external_source": "synthetic", "external_source": "synthetic",
}, },
@@ -119,23 +201,23 @@ class Command(BaseCommand):
league=league, league=league,
season=season, season=season,
defaults={ defaults={
"games_played": 28, "games_played": games_played,
"minutes_per_game": 27.5, "minutes_per_game": minutes,
"points_per_game": row["points"], "points_per_game": points,
"assists_per_game": row["assists"], "assists_per_game": assists,
"rebounds_per_game": row["rebounds"], "rebounds_per_game": rebounds,
"steals_per_game": 1.0, "steals_per_game": 0.7 + (assists / 10),
"blocks_per_game": 0.4, "blocks_per_game": 0.2 + (rebounds / 18),
"turnovers_per_game": 1.8, "turnovers_per_game": 1.0 + (usage / 20),
"field_goal_percentage": 48.0, "field_goal_percentage": 42.5 + (true_shooting / 5),
"three_point_percentage": 37.5, "three_point_percentage": 28.0 + (points / 2.3),
"free_throw_percentage": 81.0, "free_throw_percentage": 70.0 + (usage / 2),
"efficiency_rating": row["efficiency"], "efficiency_rating": efficiency,
"true_shooting_percentage": row["ts"], "true_shooting_percentage": true_shooting,
"usage_percentage": row["usage"], "usage_percentage": usage,
"total_points": int(row["points"] * 28), "total_points": int(points * games_played),
"total_assists": int(row["assists"] * 28), "total_assists": int(assists * games_played),
"total_rebounds": int(row["rebounds"] * 28), "total_rebounds": int(rebounds * games_played),
}, },
) )
PlayerGameLog.objects.update_or_create( PlayerGameLog.objects.update_or_create(
@@ -146,10 +228,24 @@ class Command(BaseCommand):
game_date="2026-01-10", game_date="2026-01-10",
opponent="Top domestic opponent", opponent="Top domestic opponent",
defaults={ defaults={
"points": int(row["points"] + 10), "points": int(points + 10),
"assists": int(row["assists"] + 3), "assists": int(assists + 3),
"rebounds": int(row["rebounds"] + 2), "rebounds": int(rebounds + 2),
"efficiency_rating": row["efficiency"] + 10, "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-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.
## External Integrations ## 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 ## Relevant ADRs
+2
View File
@@ -14,6 +14,8 @@ Seed demo data:
docker compose run --rm backend python manage.py 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: Create an admin user:
```bash ```bash
+146 -23
View File
@@ -1,15 +1,15 @@
.shell { .shell {
width: min(1500px, calc(100vw - 32px)); width: min(1560px, calc(100vw - 28px));
margin: 0 auto; margin: 0 auto;
padding: 24px 0 32px; padding: 18px 0 28px;
} }
.topbar { .topbar {
display: flex; display: flex;
align-items: flex-end; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 20px; gap: 20px;
margin-bottom: 18px; margin-bottom: 14px;
} }
.eyebrow { .eyebrow {
@@ -28,7 +28,7 @@ h2 {
} }
h1 { h1 {
font-size: clamp(2rem, 4vw, 4rem); font-size: 3.4rem;
line-height: 0.95; line-height: 0.95;
} }
@@ -36,37 +36,47 @@ h2 {
font-size: 1.6rem; font-size: 1.6rem;
} }
.status { .scoreboard {
display: grid;
grid-template-columns: repeat(3, minmax(112px, 1fr));
gap: 8px;
}
.scoreboard div {
min-width: 112px; min-width: 112px;
padding: 12px 16px; padding: 12px 14px;
color: white; color: white;
background: var(--accent-strong); background: linear-gradient(135deg, var(--accent-strong), #253b55);
border-radius: 8px; border-radius: 8px;
text-align: right; text-align: right;
} }
.status span { .scoreboard span {
display: block; display: block;
font-size: 1.6rem; font-size: 1.6rem;
font-weight: 800; font-weight: 800;
} }
.status small { .scoreboard small {
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.75);
} }
.filters { .filters {
display: grid; 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; gap: 10px;
align-items: end; align-items: end;
padding: 14px; padding: 12px;
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.search-field {
min-width: 240px;
}
label { label {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -116,8 +126,48 @@ button {
background: #e7eeeb; 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 { .alert {
margin: 14px 0 0; margin: 10px 0 0;
padding: 12px 14px; padding: 12px 14px;
color: #7a1d17; color: #7a1d17;
background: #fff1ee; background: #fff1ee;
@@ -127,21 +177,25 @@ button {
.workspace { .workspace {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 360px; grid-template-columns: minmax(0, 1fr) 390px;
gap: 16px; gap: 16px;
margin-top: 16px; margin-top: 8px;
align-items: start; align-items: start;
} }
.table-wrap, .table-wrap,
.detail { .detail {
overflow: hidden;
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.table-wrap {
max-height: calc(100vh - 238px);
overflow: auto;
}
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -150,13 +204,16 @@ table {
th, th,
td { td {
padding: 13px 12px; padding: 10px 12px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
text-align: left; text-align: left;
vertical-align: middle; vertical-align: middle;
} }
th { th {
position: sticky;
top: 0;
z-index: 1;
color: var(--muted); color: var(--muted);
background: #f8faf7; background: #f8faf7;
font-size: 0.72rem; font-size: 0.72rem;
@@ -193,6 +250,8 @@ td small {
display: grid; display: grid;
gap: 18px; gap: 18px;
padding: 18px; padding: 18px;
position: sticky;
top: 14px;
} }
.role { .role {
@@ -200,14 +259,37 @@ td small {
color: var(--muted); 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, .bio-grid,
.metric-grid { .metric-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px; gap: 10px;
margin: 0; 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, .bio-grid div,
.metric-grid div { .metric-grid div {
min-height: 82px; min-height: 82px;
@@ -234,7 +316,7 @@ dd {
display: block; display: block;
margin-top: 8px; margin-top: 8px;
color: var(--accent-strong); color: var(--accent-strong);
font-size: 1.5rem; font-size: 1.45rem;
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
@@ -242,13 +324,25 @@ dd {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
.search-field {
grid-column: span 2;
}
.actions { .actions {
grid-column: span 4; grid-column: span 2;
} }
.workspace { .workspace {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.table-wrap {
max-height: none;
}
.detail {
position: static;
}
} }
@media (max-width: 760px) { @media (max-width: 760px) {
@@ -257,12 +351,19 @@ dd {
padding-top: 14px; padding-top: 14px;
} }
.topbar { .topbar,
.quickbar {
align-items: stretch; 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; text-align: left;
} }
@@ -270,6 +371,10 @@ dd {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.search-field {
grid-column: span 2;
}
.actions { .actions {
grid-column: span 2; grid-column: span 2;
} }
@@ -281,4 +386,22 @@ dd {
table { table {
min-width: 820px; 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> <p class="eyebrow">Private basketball scouting</p>
<h1>HoopScout</h1> <h1>HoopScout</h1>
</div> </div>
<div class="status"> <div class="scoreboard" aria-label="Scouting summary">
<span>{{ resultCount }}</span> <div>
<small>matches</small> <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> </div>
</header> </header>
<section class="filters" aria-label="Player filters"> <section class="filters" aria-label="Player filters">
<label> <label class="search-field">
Search 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>
<label> <label>
Position Position
<select [(ngModel)]="filters.position"> <select [(ngModel)]="filters.position" (ngModelChange)="onFilterChange()">
<option *ngFor="let position of positions" [value]="position">{{ position || 'Any' }}</option> <option value="">Any</option>
<option *ngFor="let position of positions" [value]="position">{{ position }}</option>
</select> </select>
</label> </label>
<label> <label>
Role 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>
<label> <label>
League League
<select [(ngModel)]="filters.league"> <select [(ngModel)]="filters.league" (ngModelChange)="onFilterChange()">
<option *ngFor="let league of leagues" [value]="league">{{ league || 'Any' }}</option> <option value="">Any</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" min="0" step="0.1"> <input type="number" [(ngModel)]="filters.minPoints" (ngModelChange)="onFilterChange()" min="0" step="0.1">
</label> </label>
<label> <label>
Min APG 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>
<label> <label>
Min RPG 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>
<label> <label>
Min EFF 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> </label>
<div class="actions"> <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> <button type="button" class="secondary" (click)="clearFilters()">Reset</button>
</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" aria-label="Scouting workspace">
@@ -64,10 +116,18 @@
<th>Pos</th> <th>Pos</th>
<th>League</th> <th>League</th>
<th>Team</th> <th>Team</th>
<th>PPG</th> <th>
<th>APG</th> <button type="button" class="sort" [class.active]="activeSort === 'points_per_game'" (click)="sortBy('points_per_game')">PPG</button>
<th>RPG</th> </th>
<th>EFF</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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -100,6 +160,11 @@
<h2>{{ selectedPlayer.name }}</h2> <h2>{{ selectedPlayer.name }}</h2>
<p class="role">{{ selectedPlayer.position }} · {{ selectedPlayer.role || 'Role pending' }}</p> <p class="role">{{ selectedPlayer.position }} · {{ selectedPlayer.role || 'Role pending' }}</p>
</div> </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"> <dl class="bio-grid">
<div> <div>
<dt>League</dt> <dt>League</dt>
@@ -123,6 +188,14 @@
<span>PPG</span> <span>PPG</span>
<strong>{{ statValue(selectedPlayer, 'points_per_game') }}</strong> <strong>{{ statValue(selectedPlayer, 'points_per_game') }}</strong>
</div> </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> <div>
<span>TS%</span> <span>TS%</span>
<strong>{{ statValue(selectedPlayer, 'true_shooting_percentage') }}</strong> <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'; import { PlayerApiService } from './api/player-api.service';
describe('AppComponent', () => { 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', () => { it('loads players through the API service', () => {
const api = { const api = {
searchPlayers: () => searchPlayers: () =>
of({ of({
count: 1, count: samplePlayers.length,
results: [ results: 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',
},
},
],
}), }),
} as unknown as PlayerApiService; } as unknown as PlayerApiService;
const component = new AppComponent(api); const component = new AppComponent(api);
component.search(); 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.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 { FormsModule } from '@angular/forms';
import { PlayerApiService } from './api/player-api.service'; 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({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -13,8 +15,15 @@ import { PlayerFilters, PlayerSummary } from './models';
styleUrl: './app.component.css', styleUrl: './app.component.css',
}) })
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']; 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 = { filters: PlayerFilters = {
q: '', q: '',
@@ -31,6 +40,8 @@ export class AppComponent {
resultCount = 0; resultCount = 0;
loading = false; loading = false;
errorMessage = ''; errorMessage = '';
activeSort: SortKey = 'efficiency_rating';
private pendingSearch: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly playerApi: PlayerApiService) {} constructor(private readonly playerApi: PlayerApiService) {}
@@ -39,12 +50,13 @@ 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 = response.results; this.players = this.sortPlayers(response.results);
this.resultCount = response.count; this.resultCount = response.count;
this.selectedPlayer = this.players[0] ?? null; this.selectedPlayer = this.players[0] ?? null;
this.loading = false; 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 { clearFilters(): void {
this.filters = { this.filters = {
q: '', q: '',
@@ -70,6 +95,22 @@ 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 {
this.activeSort = key;
this.players = this.sortPlayers(this.players);
this.selectedPlayer = this.players[0] ?? null;
}
selectPlayer(player: PlayerSummary): void { selectPlayer(player: PlayerSummary): void {
this.selectedPlayer = player; this.selectedPlayer = player;
} }
@@ -78,4 +119,52 @@ export class AppComponent {
const value = player.stats?.[key]; const value = player.stats?.[key];
return value === undefined || value === null ? '-' : String(value); 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;
}
}
} }