diff --git a/README.md b/README.md index 6e3a83d..f64f7e6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/config/settings.py b/backend/config/settings.py index 44f9357..7a91947 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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(",") diff --git a/backend/scouting/management/commands/seed_demo_data.py b/backend/scouting/management/commands/seed_demo_data.py index 3311161..43ebb6b 100644 --- a/backend/scouting/management/commands/seed_demo_data.py +++ b/backend/scouting/management/commands/seed_demo_data.py @@ -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), }, ) diff --git a/backend/scouting/tests/test_seed_demo_data.py b/backend/scouting/tests/test_seed_demo_data.py new file mode 100644 index 0000000..55f1c4d --- /dev/null +++ b/backend/scouting/tests/test_seed_demo_data.py @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index ec9e43b..294354e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/runbook.md b/docs/runbook.md index 7d7b423..bc6eb81 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -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 diff --git a/frontend/src/app/app.component.css b/frontend/src/app/app.component.css index 533afbf..044e213 100644 --- a/frontend/src/app/app.component.css +++ b/frontend/src/app/app.component.css @@ -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; + } } diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index c10c20b..bc2e67a 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -4,55 +4,107 @@
Private basketball scouting
{{ errorMessage }}
{{ selectedPlayer.position }} ยท {{ selectedPlayer.role || 'Role pending' }}
+