generated from bisco/codex-bootstrap
feat: improve scouting dashboard and demo data
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user