generated from bisco/codex-bootstrap
Compare commits
18 Commits
main
...
dd46d7aabb
| Author | SHA1 | Date | |
|---|---|---|---|
| dd46d7aabb | |||
| f8de0e644a | |||
| 2c7ec7383b | |||
| f87f62f111 | |||
| f44a07f231 | |||
| 8909be9694 | |||
| 03b8762835 | |||
| 5cc1076225 | |||
| f2934d7924 | |||
| 1b7b4259f4 | |||
| f8244849a3 | |||
| 7101900e19 | |||
| 22f4a9159a | |||
| fe33b983a3 | |||
| 363a07c095 | |||
| a830c89f99 | |||
| eb40053770 | |||
| cc188468bc |
+7
-6
@@ -4,9 +4,9 @@ Edit this file for each repository.
|
||||
|
||||
## Project identity
|
||||
|
||||
Project name: `CHANGE_ME`
|
||||
Project description: `CHANGE_ME`
|
||||
Primary language/runtime: `CHANGE_ME`
|
||||
Project name: `HoopScout`
|
||||
Project description: `Private web application for basketball player scouting across European and selected international leagues.`
|
||||
Primary language/runtime: `Python 3.13 / Django, TypeScript / Angular`
|
||||
|
||||
## Project mode
|
||||
|
||||
@@ -14,7 +14,6 @@ Choose one:
|
||||
|
||||
```text
|
||||
project_mode: personal
|
||||
project_mode: work
|
||||
```
|
||||
|
||||
Rules:
|
||||
@@ -29,7 +28,6 @@ Enable only the profiles that apply to this repository:
|
||||
```text
|
||||
enabled_profiles:
|
||||
- docker
|
||||
- ansible
|
||||
- python
|
||||
```
|
||||
|
||||
@@ -77,7 +75,10 @@ All tests MUST be executed inside Docker containers.
|
||||
Configure the canonical test command for this repository:
|
||||
|
||||
```bash
|
||||
CHANGE_ME
|
||||
docker compose run --rm backend ruff check .
|
||||
docker compose run --rm backend pytest
|
||||
docker compose run --rm frontend npm test
|
||||
docker compose config
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
DJANGO_DEBUG=1
|
||||
DJANGO_SECRET_KEY=replace-with-a-local-secret
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:4200
|
||||
CSRF_TRUSTED_ORIGINS=http://localhost:4200
|
||||
POSTGRES_DB=hoopscout
|
||||
POSTGRES_USER=hoopscout
|
||||
POSTGRES_PASSWORD=replace-with-a-local-password
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
node_modules/
|
||||
dist/
|
||||
.angular/
|
||||
npm-debug.log*
|
||||
@@ -1,73 +1,43 @@
|
||||
# codex-bootstrap
|
||||
# HoopScout
|
||||
|
||||
A repository template for AI-assisted development with Codex.
|
||||
HoopScout is a private basketball scouting web application for searching and comparing male players across European and selected international leagues.
|
||||
|
||||
This template defines a repeatable workflow for using Codex as an autonomous coding agent that can create branches, modify code, run Docker-based tests, update documentation, write ADRs, and commit changes using Conventional Commits.
|
||||
The MVP focuses on local usage for a restricted group of users. It provides a Django REST API, PostgreSQL persistence, and an Angular scouting dashboard.
|
||||
|
||||
## Purpose
|
||||
## Stack
|
||||
|
||||
Use this template to bootstrap repositories where Codex must operate with clear rules, minimal changes, pragmatic TDD, security guardrails, and explicit documentation requirements.
|
||||
- Docker Compose
|
||||
- Django and Django REST Framework
|
||||
- PostgreSQL
|
||||
- Angular
|
||||
|
||||
## Repository structure
|
||||
## Local Development
|
||||
|
||||
```text
|
||||
.
|
||||
├── AGENTS.md
|
||||
├── README.md
|
||||
├── .codex/
|
||||
│ ├── project.md
|
||||
│ ├── workflow.md
|
||||
│ ├── security.md
|
||||
│ ├── quality.md
|
||||
│ ├── orchestration.md
|
||||
│ ├── prompts/
|
||||
│ │ ├── task.md
|
||||
│ │ ├── bugfix.md
|
||||
│ │ ├── refactor.md
|
||||
│ │ ├── security-review.md
|
||||
│ │ └── documentation.md
|
||||
│ ├── agents/
|
||||
│ │ ├── architect.md
|
||||
│ │ ├── developer.md
|
||||
│ │ ├── reviewer.md
|
||||
│ │ ├── security-reviewer.md
|
||||
│ │ ├── test-engineer.md
|
||||
│ │ └── documentation-writer.md
|
||||
│ └── profiles/
|
||||
│ ├── docker.md
|
||||
│ ├── ansible.md
|
||||
│ └── python.md
|
||||
└── docs/
|
||||
├── adr/
|
||||
│ └── 0000-template.md
|
||||
├── architecture.md
|
||||
├── deployment.md
|
||||
├── operations.md
|
||||
├── security.md
|
||||
├── testing.md
|
||||
└── runbook.md
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## How to use
|
||||
Run database migrations and seed synthetic local scouting data:
|
||||
|
||||
1. Copy this template into a new or existing repository.
|
||||
2. Edit `.codex/project.md` and configure:
|
||||
- project mode;
|
||||
- enabled profiles;
|
||||
- Docker-based test command;
|
||||
- branch naming rules if needed.
|
||||
3. Add project-specific details to the documentation under `docs/`.
|
||||
4. When asking Codex to work on a task, use one of the prompt templates under `.codex/prompts/`.
|
||||
```bash
|
||||
docker compose run --rm backend python manage.py migrate
|
||||
docker compose run --rm backend python manage.py seed_demo_data
|
||||
docker compose run --rm backend python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## Core rules
|
||||
The backend is available at `http://localhost:8000`; the Angular frontend is available at `http://localhost:4200`.
|
||||
|
||||
Codex must:
|
||||
## Tests
|
||||
|
||||
- start work from `develop`;
|
||||
- create a dedicated `feature/`, `fix/`, or `hotfix/` branch;
|
||||
- use pragmatic TDD;
|
||||
- keep changes minimal and focused;
|
||||
- run the configured Docker-based test command before completion;
|
||||
- update documentation and ADRs when needed;
|
||||
- produce a final report with summary, tests, risks, and rollback notes;
|
||||
- commit using Conventional Commits.
|
||||
All tests are run inside Docker containers:
|
||||
|
||||
```bash
|
||||
docker compose run --rm backend ruff check .
|
||||
docker compose run --rm backend pytest
|
||||
docker compose run --rm frontend npm test
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
*.pyc
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM python:3.13.5-slim-bookworm
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir --upgrade pip==25.1.1 \
|
||||
&& pip install --no-cache-dir ".[dev]"
|
||||
|
||||
COPY . .
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,91 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "local-development-secret-key-change-me")
|
||||
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
|
||||
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,backend").split(",")
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"corsheaders",
|
||||
"rest_framework",
|
||||
"scouting.apps.ScoutingConfig",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.environ.get("POSTGRES_DB", "hoopscout"),
|
||||
"USER": os.environ.get("POSTGRES_USER", "hoopscout"),
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "local-dev-password-change-me"),
|
||||
"HOST": os.environ.get("POSTGRES_HOST", "db"),
|
||||
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "static/"
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"rest_framework.authentication.BasicAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
],
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 50,
|
||||
}
|
||||
|
||||
CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:4200").split(",")
|
||||
CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "http://localhost:4200").split(",")
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from scouting.views import LeagueViewSet, PlayerViewSet, TeamViewSet, me
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("players", PlayerViewSet, basename="player")
|
||||
router.register("leagues", LeagueViewSet, basename="league")
|
||||
router.register("teams", TeamViewSet, basename="team")
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/me/", me, name="me"),
|
||||
path("api/", include(router.urls)),
|
||||
path("api-auth/", include("rest_framework.urls")),
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,30 @@
|
||||
[project]
|
||||
name = "hoopscout-backend"
|
||||
version = "0.1.0"
|
||||
description = "Django API for basketball scouting."
|
||||
requires-python = ">=3.13,<3.14"
|
||||
dependencies = [
|
||||
"Django==5.2.14",
|
||||
"django-cors-headers==4.7.0",
|
||||
"djangorestframework==3.16.1",
|
||||
"psycopg[binary]==3.2.9",
|
||||
"gunicorn==23.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest==8.4.1",
|
||||
"pytest-django==4.11.1",
|
||||
"ruff==0.12.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "config.settings"
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import League, Player, PlayerGameLog, PlayerSeasonStat, Season, Team, UserProfile
|
||||
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "role")
|
||||
list_filter = ("role",)
|
||||
search_fields = ("user__username",)
|
||||
|
||||
|
||||
@admin.register(League)
|
||||
class LeagueAdmin(admin.ModelAdmin):
|
||||
list_display = ("code", "name", "region", "country")
|
||||
list_filter = ("region", "country")
|
||||
search_fields = ("name", "code", "country")
|
||||
|
||||
|
||||
@admin.register(Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "league", "country")
|
||||
list_filter = ("league", "country")
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(Season)
|
||||
class SeasonAdmin(admin.ModelAdmin):
|
||||
list_display = ("label", "is_active")
|
||||
list_filter = ("is_active",)
|
||||
|
||||
|
||||
@admin.register(Player)
|
||||
class PlayerAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "position", "role", "current_team", "height_cm", "weight_kg", "nationality")
|
||||
list_filter = ("position", "current_team__league", "nationality")
|
||||
search_fields = ("first_name", "last_name", "role", "nationality")
|
||||
|
||||
|
||||
@admin.register(PlayerSeasonStat)
|
||||
class PlayerSeasonStatAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"player",
|
||||
"season",
|
||||
"league",
|
||||
"points_per_game",
|
||||
"assists_per_game",
|
||||
"rebounds_per_game",
|
||||
"efficiency_rating",
|
||||
)
|
||||
list_filter = ("season", "league")
|
||||
search_fields = ("player__first_name", "player__last_name")
|
||||
|
||||
|
||||
@admin.register(PlayerGameLog)
|
||||
class PlayerGameLogAdmin(admin.ModelAdmin):
|
||||
list_display = ("player", "game_date", "league", "opponent", "points", "assists", "rebounds", "efficiency_rating")
|
||||
list_filter = ("season", "league")
|
||||
search_fields = ("player__first_name", "player__last_name", "opponent")
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ScoutingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "scouting"
|
||||
|
||||
def ready(self) -> None:
|
||||
from . import signals # noqa: F401
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from scouting.models import League, Player, PlayerGameLog, PlayerSeasonStat, Season, Team
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed synthetic European scouting data for local development."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
season, _ = Season.objects.update_or_create(label="2025-26", defaults={"is_active": True})
|
||||
leagues = {
|
||||
"LBA": League.objects.update_or_create(
|
||||
code="LBA",
|
||||
defaults={"name": "Lega Basket Serie A", "region": "Europe", "country": "Italy"},
|
||||
)[0],
|
||||
"ACB": League.objects.update_or_create(
|
||||
code="ACB",
|
||||
defaults={"name": "Liga Endesa", "region": "Europe", "country": "Spain"},
|
||||
)[0],
|
||||
"ABA": League.objects.update_or_create(
|
||||
code="ABA",
|
||||
defaults={"name": "ABA League", "region": "Europe", "country": "Adriatic"},
|
||||
)[0],
|
||||
"BBL": League.objects.update_or_create(
|
||||
code="BBL",
|
||||
defaults={"name": "Basketball Bundesliga", "region": "Europe", "country": "Germany"},
|
||||
)[0],
|
||||
"BSL": League.objects.update_or_create(
|
||||
code="BSL",
|
||||
defaults={"name": "Basketbol Super Ligi", "region": "Europe", "country": "Turkey"},
|
||||
)[0],
|
||||
"LNB": League.objects.update_or_create(
|
||||
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 = [
|
||||
(
|
||||
"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:
|
||||
(
|
||||
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=team_name,
|
||||
league=league,
|
||||
defaults={"country": league.country},
|
||||
)
|
||||
player, _ = Player.objects.update_or_create(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
birth_year=birth_year,
|
||||
nationality=nationality,
|
||||
defaults={
|
||||
"position": position,
|
||||
"role": role,
|
||||
"height_cm": height_cm,
|
||||
"weight_kg": weight_kg,
|
||||
"current_team": team,
|
||||
"external_source": "synthetic",
|
||||
},
|
||||
)
|
||||
PlayerSeasonStat.objects.update_or_create(
|
||||
player=player,
|
||||
team=team,
|
||||
league=league,
|
||||
season=season,
|
||||
defaults={
|
||||
"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(
|
||||
player=player,
|
||||
team=team,
|
||||
league=league,
|
||||
season=season,
|
||||
game_date="2026-01-10",
|
||||
opponent="Top domestic opponent",
|
||||
defaults={
|
||||
"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),
|
||||
},
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Seeded synthetic HoopScout demo data."))
|
||||
@@ -0,0 +1,242 @@
|
||||
# Generated for the HoopScout MVP.
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="League",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("name", models.CharField(max_length=120)),
|
||||
("code", models.CharField(max_length=16, unique=True)),
|
||||
("region", models.CharField(max_length=60)),
|
||||
("country", models.CharField(max_length=80)),
|
||||
],
|
||||
options={"ordering": ["region", "country", "name"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Season",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("label", models.CharField(max_length=20, unique=True)),
|
||||
("is_active", models.BooleanField(default=False)),
|
||||
],
|
||||
options={"ordering": ["-label"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Team",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("name", models.CharField(max_length=120)),
|
||||
("country", models.CharField(max_length=80)),
|
||||
(
|
||||
"league",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="teams",
|
||||
to="scouting.league",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
"constraints": [
|
||||
models.UniqueConstraint(fields=("name", "league"), name="unique_team_per_league"),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserProfile",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[("admin", "Admin"), ("scout", "Scout"), ("viewer", "Viewer")],
|
||||
default="viewer",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="profile",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Player",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("first_name", models.CharField(max_length=80)),
|
||||
("last_name", models.CharField(max_length=80)),
|
||||
(
|
||||
"position",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("PG", "Point Guard"),
|
||||
("SG", "Shooting Guard"),
|
||||
("SF", "Small Forward"),
|
||||
("PF", "Power Forward"),
|
||||
("C", "Center"),
|
||||
],
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
("role", models.CharField(blank=True, max_length=120)),
|
||||
("birth_year", models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
("height_cm", models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
("weight_kg", models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
("nationality", models.CharField(blank=True, max_length=80)),
|
||||
("external_source", models.CharField(blank=True, max_length=80)),
|
||||
("external_id", models.CharField(blank=True, max_length=120)),
|
||||
("profile_url", models.URLField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"current_team",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="players",
|
||||
to="scouting.team",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["last_name", "first_name"],
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("first_name", "last_name", "birth_year", "nationality"),
|
||||
name="unique_player_identity",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PlayerGameLog",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("opponent", models.CharField(max_length=120)),
|
||||
("game_date", models.DateField()),
|
||||
("points", models.PositiveSmallIntegerField(default=0)),
|
||||
("assists", models.PositiveSmallIntegerField(default=0)),
|
||||
("rebounds", models.PositiveSmallIntegerField(default=0)),
|
||||
("steals", models.PositiveSmallIntegerField(default=0)),
|
||||
("blocks", models.PositiveSmallIntegerField(default=0)),
|
||||
("turnovers", models.PositiveSmallIntegerField(default=0)),
|
||||
("efficiency_rating", models.DecimalField(decimal_places=2, default=0, max_digits=6)),
|
||||
(
|
||||
"league",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="game_logs",
|
||||
to="scouting.league",
|
||||
),
|
||||
),
|
||||
(
|
||||
"player",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="game_logs",
|
||||
to="scouting.player",
|
||||
),
|
||||
),
|
||||
(
|
||||
"season",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="game_logs",
|
||||
to="scouting.season",
|
||||
),
|
||||
),
|
||||
(
|
||||
"team",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="game_logs",
|
||||
to="scouting.team",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"ordering": ["-game_date"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PlayerSeasonStat",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("games_played", models.PositiveSmallIntegerField(default=0)),
|
||||
("minutes_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("points_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("assists_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("rebounds_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("steals_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("blocks_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("turnovers_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("field_goal_percentage", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("three_point_percentage", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("free_throw_percentage", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("efficiency_rating", models.DecimalField(decimal_places=2, default=0, max_digits=6)),
|
||||
("true_shooting_percentage", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("usage_percentage", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("total_points", models.PositiveSmallIntegerField(default=0)),
|
||||
("total_assists", models.PositiveSmallIntegerField(default=0)),
|
||||
("total_rebounds", models.PositiveSmallIntegerField(default=0)),
|
||||
(
|
||||
"league",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="season_stats",
|
||||
to="scouting.league",
|
||||
),
|
||||
),
|
||||
(
|
||||
"player",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="season_stats",
|
||||
to="scouting.player",
|
||||
),
|
||||
),
|
||||
(
|
||||
"season",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="player_stats",
|
||||
to="scouting.season",
|
||||
),
|
||||
),
|
||||
(
|
||||
"team",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="season_stats",
|
||||
to="scouting.team",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-efficiency_rating", "-points_per_game"],
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("player", "team", "league", "season"),
|
||||
name="unique_player_stat_line",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
ROLE_ADMIN = "admin"
|
||||
ROLE_SCOUT = "scout"
|
||||
ROLE_VIEWER = "viewer"
|
||||
ROLE_CHOICES = [
|
||||
(ROLE_ADMIN, "Admin"),
|
||||
(ROLE_SCOUT, "Scout"),
|
||||
(ROLE_VIEWER, "Viewer"),
|
||||
]
|
||||
|
||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile")
|
||||
role = models.CharField(max_length=16, choices=ROLE_CHOICES, default=ROLE_VIEWER)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user.username} ({self.role})"
|
||||
|
||||
|
||||
class League(models.Model):
|
||||
name = models.CharField(max_length=120)
|
||||
code = models.CharField(max_length=16, unique=True)
|
||||
region = models.CharField(max_length=60)
|
||||
country = models.CharField(max_length=80)
|
||||
|
||||
class Meta:
|
||||
ordering = ["region", "country", "name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.code
|
||||
|
||||
|
||||
class Team(models.Model):
|
||||
name = models.CharField(max_length=120)
|
||||
league = models.ForeignKey(League, on_delete=models.PROTECT, related_name="teams")
|
||||
country = models.CharField(max_length=80)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
constraints = [models.UniqueConstraint(fields=["name", "league"], name="unique_team_per_league")]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class Season(models.Model):
|
||||
label = models.CharField(max_length=20, unique=True)
|
||||
is_active = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-label"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.label
|
||||
|
||||
|
||||
class Player(models.Model):
|
||||
POSITION_CHOICES = [
|
||||
("PG", "Point Guard"),
|
||||
("SG", "Shooting Guard"),
|
||||
("SF", "Small Forward"),
|
||||
("PF", "Power Forward"),
|
||||
("C", "Center"),
|
||||
]
|
||||
|
||||
first_name = models.CharField(max_length=80)
|
||||
last_name = models.CharField(max_length=80)
|
||||
position = models.CharField(max_length=2, choices=POSITION_CHOICES)
|
||||
role = models.CharField(max_length=120, blank=True)
|
||||
birth_year = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
height_cm = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
weight_kg = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
nationality = models.CharField(max_length=80, blank=True)
|
||||
current_team = models.ForeignKey(Team, on_delete=models.SET_NULL, null=True, blank=True, related_name="players")
|
||||
external_source = models.CharField(max_length=80, blank=True)
|
||||
external_id = models.CharField(max_length=120, blank=True)
|
||||
profile_url = models.URLField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["last_name", "first_name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["first_name", "last_name", "birth_year", "nationality"],
|
||||
name="unique_player_identity",
|
||||
)
|
||||
]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class PlayerSeasonStat(models.Model):
|
||||
player = models.ForeignKey(Player, on_delete=models.CASCADE, related_name="season_stats")
|
||||
team = models.ForeignKey(Team, on_delete=models.PROTECT, related_name="season_stats")
|
||||
league = models.ForeignKey(League, on_delete=models.PROTECT, related_name="season_stats")
|
||||
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="player_stats")
|
||||
games_played = models.PositiveSmallIntegerField(default=0)
|
||||
minutes_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
points_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
assists_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
rebounds_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
steals_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
blocks_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
turnovers_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
field_goal_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
three_point_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
free_throw_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
efficiency_rating = models.DecimalField(max_digits=6, decimal_places=2, default=0)
|
||||
true_shooting_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
usage_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
total_points = models.PositiveSmallIntegerField(default=0)
|
||||
total_assists = models.PositiveSmallIntegerField(default=0)
|
||||
total_rebounds = models.PositiveSmallIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-efficiency_rating", "-points_per_game"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["player", "team", "league", "season"], name="unique_player_stat_line")
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.player} {self.season} {self.league}"
|
||||
|
||||
|
||||
class PlayerGameLog(models.Model):
|
||||
player = models.ForeignKey(Player, on_delete=models.CASCADE, related_name="game_logs")
|
||||
team = models.ForeignKey(Team, on_delete=models.PROTECT, related_name="game_logs")
|
||||
opponent = models.CharField(max_length=120)
|
||||
league = models.ForeignKey(League, on_delete=models.PROTECT, related_name="game_logs")
|
||||
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="game_logs")
|
||||
game_date = models.DateField()
|
||||
points = models.PositiveSmallIntegerField(default=0)
|
||||
assists = models.PositiveSmallIntegerField(default=0)
|
||||
rebounds = models.PositiveSmallIntegerField(default=0)
|
||||
steals = models.PositiveSmallIntegerField(default=0)
|
||||
blocks = models.PositiveSmallIntegerField(default=0)
|
||||
turnovers = models.PositiveSmallIntegerField(default=0)
|
||||
efficiency_rating = models.DecimalField(max_digits=6, decimal_places=2, default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-game_date"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.player} vs {self.opponent} on {self.game_date}"
|
||||
@@ -0,0 +1,113 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import League, Player, PlayerGameLog, PlayerSeasonStat, Team
|
||||
|
||||
|
||||
class LeagueSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = League
|
||||
fields = ["name", "code", "region", "country"]
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = ["name", "country"]
|
||||
|
||||
|
||||
class PlayerSeasonStatSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PlayerSeasonStat
|
||||
fields = [
|
||||
"games_played",
|
||||
"minutes_per_game",
|
||||
"points_per_game",
|
||||
"assists_per_game",
|
||||
"rebounds_per_game",
|
||||
"steals_per_game",
|
||||
"blocks_per_game",
|
||||
"turnovers_per_game",
|
||||
"field_goal_percentage",
|
||||
"three_point_percentage",
|
||||
"free_throw_percentage",
|
||||
"efficiency_rating",
|
||||
"true_shooting_percentage",
|
||||
"usage_percentage",
|
||||
"total_points",
|
||||
"total_assists",
|
||||
"total_rebounds",
|
||||
]
|
||||
|
||||
|
||||
class PlayerGameLogSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PlayerGameLog
|
||||
fields = [
|
||||
"game_date",
|
||||
"opponent",
|
||||
"points",
|
||||
"assists",
|
||||
"rebounds",
|
||||
"steals",
|
||||
"blocks",
|
||||
"turnovers",
|
||||
"efficiency_rating",
|
||||
]
|
||||
|
||||
|
||||
class PlayerListSerializer(serializers.ModelSerializer):
|
||||
name = serializers.CharField(read_only=True)
|
||||
league = serializers.SerializerMethodField()
|
||||
team = serializers.SerializerMethodField()
|
||||
stats = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Player
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"position",
|
||||
"role",
|
||||
"birth_year",
|
||||
"height_cm",
|
||||
"weight_kg",
|
||||
"nationality",
|
||||
"league",
|
||||
"team",
|
||||
"stats",
|
||||
]
|
||||
|
||||
def get_league(self, player: Player) -> dict | None:
|
||||
stat = self._current_stat(player)
|
||||
return LeagueSerializer(stat.league).data if stat else None
|
||||
|
||||
def get_team(self, player: Player) -> dict | None:
|
||||
stat = self._current_stat(player)
|
||||
team = stat.team if stat else player.current_team
|
||||
return TeamSerializer(team).data if team else None
|
||||
|
||||
def get_stats(self, player: Player) -> dict | None:
|
||||
stat = self._current_stat(player)
|
||||
return PlayerSeasonStatSerializer(stat).data if stat else None
|
||||
|
||||
def _current_stat(self, player: Player) -> PlayerSeasonStat | None:
|
||||
prefetched = getattr(player, "prefetched_stats", None)
|
||||
if prefetched is not None:
|
||||
return prefetched[0] if prefetched else None
|
||||
return player.season_stats.select_related("league", "team", "season").first()
|
||||
|
||||
|
||||
class PlayerDetailSerializer(PlayerListSerializer):
|
||||
best_game = serializers.SerializerMethodField()
|
||||
worst_game = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(PlayerListSerializer.Meta):
|
||||
fields = PlayerListSerializer.Meta.fields + ["external_source", "profile_url", "best_game", "worst_game"]
|
||||
|
||||
def get_best_game(self, player: Player) -> dict | None:
|
||||
game = player.game_logs.order_by("-efficiency_rating", "-points").first()
|
||||
return PlayerGameLogSerializer(game).data if game else None
|
||||
|
||||
def get_worst_game(self, player: Player) -> dict | None:
|
||||
game = player.game_logs.order_by("efficiency_rating", "points").first()
|
||||
return PlayerGameLogSerializer(game).data if game else None
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .models import UserProfile
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def ensure_user_profile(sender, instance: User, created: bool, **kwargs) -> None:
|
||||
if created:
|
||||
UserProfile.objects.create(user=instance)
|
||||
@@ -0,0 +1,233 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from scouting.models import League, Player, PlayerGameLog, PlayerSeasonStat, Season, Team
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scout_user(db):
|
||||
user = User.objects.create_user(username="scout", password="test-password")
|
||||
user.profile.role = "scout"
|
||||
user.profile.save(update_fields=["role"])
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(db):
|
||||
lba = League.objects.create(name="Lega Basket Serie A", code="LBA", region="Europe", country="Italy")
|
||||
endesa = League.objects.create(name="Liga Endesa", code="ACB", region="Europe", country="Spain")
|
||||
nba = League.objects.create(
|
||||
name="National Basketball Association",
|
||||
code="NBA",
|
||||
region="North America",
|
||||
country="USA",
|
||||
)
|
||||
season = Season.objects.create(label="2025-26", is_active=True)
|
||||
|
||||
milano = Team.objects.create(name="Milano", league=lba, country="Italy")
|
||||
madrid = Team.objects.create(name="Madrid", league=endesa, country="Spain")
|
||||
boston = Team.objects.create(name="Boston", league=nba, country="USA")
|
||||
|
||||
guard = Player.objects.create(
|
||||
first_name="Luca",
|
||||
last_name="Marini",
|
||||
position="PG",
|
||||
role="Primary ball handler",
|
||||
birth_year=2001,
|
||||
height_cm=190,
|
||||
weight_kg=86,
|
||||
nationality="Italy",
|
||||
current_team=milano,
|
||||
)
|
||||
wing = Player.objects.create(
|
||||
first_name="Mateo",
|
||||
last_name="Santos",
|
||||
position="SF",
|
||||
role="3 and D wing",
|
||||
birth_year=1999,
|
||||
height_cm=201,
|
||||
weight_kg=96,
|
||||
nationality="Spain",
|
||||
current_team=madrid,
|
||||
)
|
||||
nba_big = Player.objects.create(
|
||||
first_name="Cole",
|
||||
last_name="Anderson",
|
||||
position="C",
|
||||
role="Rim runner",
|
||||
birth_year=1998,
|
||||
height_cm=211,
|
||||
weight_kg=112,
|
||||
nationality="USA",
|
||||
current_team=boston,
|
||||
)
|
||||
|
||||
PlayerSeasonStat.objects.create(
|
||||
player=guard,
|
||||
team=milano,
|
||||
league=lba,
|
||||
season=season,
|
||||
games_played=28,
|
||||
minutes_per_game=29.4,
|
||||
points_per_game=16.8,
|
||||
assists_per_game=6.2,
|
||||
rebounds_per_game=3.8,
|
||||
steals_per_game=1.4,
|
||||
blocks_per_game=0.1,
|
||||
turnovers_per_game=2.3,
|
||||
field_goal_percentage=48.1,
|
||||
three_point_percentage=39.6,
|
||||
free_throw_percentage=84.2,
|
||||
efficiency_rating=20.5,
|
||||
true_shooting_percentage=59.2,
|
||||
usage_percentage=25.0,
|
||||
total_points=470,
|
||||
total_assists=174,
|
||||
total_rebounds=106,
|
||||
)
|
||||
PlayerSeasonStat.objects.create(
|
||||
player=wing,
|
||||
team=madrid,
|
||||
league=endesa,
|
||||
season=season,
|
||||
games_played=25,
|
||||
minutes_per_game=24.1,
|
||||
points_per_game=11.2,
|
||||
assists_per_game=2.1,
|
||||
rebounds_per_game=5.5,
|
||||
steals_per_game=1.1,
|
||||
blocks_per_game=0.5,
|
||||
turnovers_per_game=1.0,
|
||||
field_goal_percentage=44.0,
|
||||
three_point_percentage=37.4,
|
||||
free_throw_percentage=79.3,
|
||||
efficiency_rating=13.7,
|
||||
true_shooting_percentage=55.8,
|
||||
usage_percentage=17.5,
|
||||
total_points=280,
|
||||
total_assists=52,
|
||||
total_rebounds=138,
|
||||
)
|
||||
PlayerSeasonStat.objects.create(
|
||||
player=nba_big,
|
||||
team=boston,
|
||||
league=nba,
|
||||
season=season,
|
||||
games_played=30,
|
||||
minutes_per_game=18.2,
|
||||
points_per_game=8.4,
|
||||
assists_per_game=1.0,
|
||||
rebounds_per_game=7.8,
|
||||
steals_per_game=0.4,
|
||||
blocks_per_game=1.9,
|
||||
turnovers_per_game=1.2,
|
||||
field_goal_percentage=62.0,
|
||||
three_point_percentage=0,
|
||||
free_throw_percentage=68.0,
|
||||
efficiency_rating=12.1,
|
||||
true_shooting_percentage=63.4,
|
||||
usage_percentage=14.1,
|
||||
total_points=252,
|
||||
total_assists=30,
|
||||
total_rebounds=234,
|
||||
)
|
||||
|
||||
PlayerGameLog.objects.create(
|
||||
player=guard,
|
||||
team=milano,
|
||||
opponent="Bologna",
|
||||
league=lba,
|
||||
season=season,
|
||||
game_date="2026-01-10",
|
||||
points=28,
|
||||
assists=9,
|
||||
rebounds=5,
|
||||
efficiency_rating=32.0,
|
||||
)
|
||||
PlayerGameLog.objects.create(
|
||||
player=guard,
|
||||
team=milano,
|
||||
opponent="Venezia",
|
||||
league=lba,
|
||||
season=season,
|
||||
game_date="2026-01-17",
|
||||
points=6,
|
||||
assists=3,
|
||||
rebounds=1,
|
||||
efficiency_rating=4.0,
|
||||
)
|
||||
|
||||
return {"guard": guard, "wing": wing, "nba_big": nba_big}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_players_api_requires_authentication(api_client):
|
||||
response = api_client.get(reverse("player-list"))
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_players_can_be_filtered_by_name_position_league_and_stats(api_client, scout_user, sample_data):
|
||||
api_client.force_authenticate(scout_user)
|
||||
|
||||
response = api_client.get(
|
||||
reverse("player-list"),
|
||||
{
|
||||
"q": "luca",
|
||||
"position": "PG",
|
||||
"league": "LBA",
|
||||
"points_per_game__gte": "15",
|
||||
"assists_per_game__gte": "5",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["count"] == 1
|
||||
assert payload["results"][0]["name"] == "Luca Marini"
|
||||
assert payload["results"][0]["league"]["code"] == "LBA"
|
||||
assert payload["results"][0]["stats"]["points_per_game"] == "16.80"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_players_are_ranked_by_efficiency_then_points(api_client, scout_user, sample_data):
|
||||
api_client.force_authenticate(scout_user)
|
||||
|
||||
response = api_client.get(reverse("player-list"), {"region": "Europe"})
|
||||
|
||||
assert response.status_code == 200
|
||||
names = [player["name"] for player in response.json()["results"]]
|
||||
assert names == ["Luca Marini", "Mateo Santos"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_player_detail_exposes_best_and_worst_performances(api_client, scout_user, sample_data):
|
||||
api_client.force_authenticate(scout_user)
|
||||
|
||||
response = api_client.get(reverse("player-detail", args=[sample_data["guard"].id]))
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["name"] == "Luca Marini"
|
||||
assert payload["best_game"]["opponent"] == "Bologna"
|
||||
assert payload["best_game"]["efficiency_rating"] == "32.00"
|
||||
assert payload["worst_game"]["opponent"] == "Venezia"
|
||||
assert payload["worst_game"]["efficiency_rating"] == "4.00"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_me_endpoint_returns_user_role(api_client, scout_user):
|
||||
api_client.force_authenticate(scout_user)
|
||||
|
||||
response = api_client.get(reverse("me"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"username": "scout", "role": "scout"}
|
||||
@@ -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
|
||||
@@ -0,0 +1,128 @@
|
||||
from django.db.models import Prefetch, Q
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import League, Player, PlayerSeasonStat, Team
|
||||
from .serializers import (
|
||||
LeagueSerializer,
|
||||
PlayerDetailSerializer,
|
||||
PlayerListSerializer,
|
||||
TeamSerializer,
|
||||
)
|
||||
|
||||
STAT_FILTERS = {
|
||||
"games_played": "season_stats__games_played",
|
||||
"minutes_per_game": "season_stats__minutes_per_game",
|
||||
"points_per_game": "season_stats__points_per_game",
|
||||
"assists_per_game": "season_stats__assists_per_game",
|
||||
"rebounds_per_game": "season_stats__rebounds_per_game",
|
||||
"steals_per_game": "season_stats__steals_per_game",
|
||||
"blocks_per_game": "season_stats__blocks_per_game",
|
||||
"turnovers_per_game": "season_stats__turnovers_per_game",
|
||||
"field_goal_percentage": "season_stats__field_goal_percentage",
|
||||
"three_point_percentage": "season_stats__three_point_percentage",
|
||||
"free_throw_percentage": "season_stats__free_throw_percentage",
|
||||
"efficiency_rating": "season_stats__efficiency_rating",
|
||||
"true_shooting_percentage": "season_stats__true_shooting_percentage",
|
||||
"usage_percentage": "season_stats__usage_percentage",
|
||||
"total_points": "season_stats__total_points",
|
||||
"total_assists": "season_stats__total_assists",
|
||||
"total_rebounds": "season_stats__total_rebounds",
|
||||
}
|
||||
|
||||
PLAYER_FILTERS = {
|
||||
"position": "position__iexact",
|
||||
"role": "role__icontains",
|
||||
"nationality": "nationality__icontains",
|
||||
"height_cm": "height_cm",
|
||||
"weight_kg": "weight_kg",
|
||||
"birth_year": "birth_year",
|
||||
}
|
||||
|
||||
LOOKUPS = {"exact", "gte", "lte", "gt", "lt"}
|
||||
|
||||
|
||||
class PlayerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "retrieve":
|
||||
return PlayerDetailSerializer
|
||||
return PlayerListSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
stats = PlayerSeasonStat.objects.select_related("league", "team", "season").order_by(
|
||||
"-efficiency_rating", "-points_per_game"
|
||||
)
|
||||
queryset = (
|
||||
Player.objects.select_related("current_team", "current_team__league")
|
||||
.prefetch_related(Prefetch("season_stats", queryset=stats, to_attr="prefetched_stats"))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
params = self.request.query_params
|
||||
if query := params.get("q"):
|
||||
queryset = queryset.filter(
|
||||
Q(first_name__icontains=query)
|
||||
| Q(last_name__icontains=query)
|
||||
| Q(role__icontains=query)
|
||||
| Q(nationality__icontains=query)
|
||||
| Q(current_team__name__icontains=query)
|
||||
)
|
||||
|
||||
if league := params.get("league"):
|
||||
queryset = queryset.filter(
|
||||
Q(season_stats__league__code__iexact=league)
|
||||
| Q(season_stats__league__name__icontains=league)
|
||||
| Q(current_team__league__code__iexact=league)
|
||||
)
|
||||
|
||||
if region := params.get("region"):
|
||||
queryset = queryset.filter(season_stats__league__region__iexact=region)
|
||||
|
||||
if team := params.get("team"):
|
||||
queryset = queryset.filter(
|
||||
Q(season_stats__team__name__icontains=team) | Q(current_team__name__icontains=team)
|
||||
)
|
||||
|
||||
if season := params.get("season"):
|
||||
queryset = queryset.filter(season_stats__season__label=season)
|
||||
|
||||
for param, field in PLAYER_FILTERS.items():
|
||||
if params.get(param):
|
||||
queryset = queryset.filter(**{field: params[param]})
|
||||
for lookup in LOOKUPS - {"exact"}:
|
||||
key = f"{param}__{lookup}"
|
||||
if params.get(key):
|
||||
queryset = queryset.filter(**{f"{param}__{lookup}": params[key]})
|
||||
|
||||
for param, field in STAT_FILTERS.items():
|
||||
if params.get(param):
|
||||
queryset = queryset.filter(**{field: params[param]})
|
||||
for lookup in LOOKUPS - {"exact"}:
|
||||
key = f"{param}__{lookup}"
|
||||
if params.get(key):
|
||||
queryset = queryset.filter(**{f"{field}__{lookup}": params[key]})
|
||||
|
||||
return queryset.order_by("-season_stats__efficiency_rating", "-season_stats__points_per_game", "last_name")
|
||||
|
||||
|
||||
class LeagueViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
queryset = League.objects.all()
|
||||
serializer_class = LeagueSerializer
|
||||
|
||||
|
||||
class TeamViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
queryset = Team.objects.select_related("league").all()
|
||||
serializer_class = TeamSerializer
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def me(request):
|
||||
role = getattr(request.user.profile, "role", "viewer")
|
||||
return Response({"username": request.user.username, "role": role})
|
||||
@@ -0,0 +1,63 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:17.10-bookworm
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-hoopscout}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-hoopscout}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-local-dev-password-change-me}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- hoopscout
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
environment:
|
||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-1}
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-local-development-secret-key-change-me}
|
||||
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,backend}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:4200}
|
||||
CSRF_TRUSTED_ORIGINS: ${CSRF_TRUSTED_ORIGINS:-http://localhost:4200}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-hoopscout}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-hoopscout}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-local-dev-password-change-me}
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_PORT: 5432
|
||||
RUFF_CACHE_DIR: /tmp/ruff_cache
|
||||
PYTEST_ADDOPTS: -o cache_dir=/tmp/pytest_cache
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
networks:
|
||||
- hoopscout
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "4200:4200"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- frontend_node_modules:/app/node_modules
|
||||
networks:
|
||||
- hoopscout
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
frontend_node_modules:
|
||||
|
||||
networks:
|
||||
hoopscout:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,48 @@
|
||||
# ADR-0001: Bootstrap Stack and Data Boundaries
|
||||
|
||||
Date: 2026-06-03
|
||||
|
||||
Status: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
HoopScout needs a private MVP for scouting male basketball players, initially focused on European leagues and a single season. The requested stack is Docker, Django, PostgreSQL, and Angular. Development must follow pragmatic TDD and keep implementation choices simple.
|
||||
|
||||
External sources such as RealGM and Proballers may be useful, but the project does not yet have authorized API credentials, provider terms review, or a data license.
|
||||
|
||||
## Decision
|
||||
|
||||
Use Docker Compose with:
|
||||
|
||||
- Django REST Framework for the backend API and Django admin;
|
||||
- PostgreSQL for persistent relational data;
|
||||
- Angular for the scouting dashboard;
|
||||
- synthetic demo data through a Django management command;
|
||||
- authenticated API access through Django session/basic authentication;
|
||||
- user profile roles modeled as `admin`, `scout`, and `viewer`.
|
||||
|
||||
Do not add automated scraping or copied external datasets in the MVP. Keep provider metadata fields so future authorized imports can preserve source references.
|
||||
|
||||
## Consequences
|
||||
|
||||
The MVP can be run locally and tested inside containers with a small, understandable architecture. The schema supports player identity, position, optional role, league, team, season averages, totals, advanced statistics, and best/worst game summaries.
|
||||
|
||||
Real data ingestion remains a later feature and must be designed around provider authorization and licensing.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Scrape RealGM or Proballers immediately: rejected because provider authorization and terms are not yet documented.
|
||||
- Add JWT authentication immediately: deferred because Django session/basic authentication is enough for local restricted use.
|
||||
- Add notes, exports, and watchlists immediately: deferred because they are outside the initial MVP scope.
|
||||
|
||||
## Security impact
|
||||
|
||||
All API endpoints require authentication. Secrets are read from environment variables and `.env` is ignored by Git. Containers use non-root users where practical. PostgreSQL is not exposed outside the Compose network.
|
||||
|
||||
## Operational impact
|
||||
|
||||
The application is local-only. Production deployment, TLS, backup automation, stricter role permissions, and source ingestion jobs require later ADRs.
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the bootstrap commit and remove local Docker volumes if database state can be discarded.
|
||||
+28
-9
@@ -1,13 +1,32 @@
|
||||
# Architecture
|
||||
|
||||
Describe the project architecture here.
|
||||
HoopScout is a private scouting application composed of three local Docker services:
|
||||
|
||||
Include:
|
||||
- `frontend`: Angular single-page application for player search and profile review.
|
||||
- `backend`: Django REST Framework API with Django admin for restricted data management.
|
||||
- `db`: PostgreSQL database for users, leagues, teams, players, season stats, and game logs.
|
||||
|
||||
- main components;
|
||||
- runtime dependencies;
|
||||
- data flow;
|
||||
- persistence;
|
||||
- external integrations;
|
||||
- deployment topology;
|
||||
- relevant ADRs.
|
||||
## Data Flow
|
||||
|
||||
Users authenticate through Django session/basic authentication. The Angular application calls `/api/players/` with search and filter query parameters. The backend returns paginated player summaries ranked by efficiency and points, plus detailed player profiles at `/api/players/{id}/`.
|
||||
|
||||
## Persistence
|
||||
|
||||
The initial schema stores:
|
||||
|
||||
- users and user profiles with `admin`, `scout`, and `viewer` roles;
|
||||
- male player identity, bio, position, optional role, measurements in cm/kg, nationality, current team, and external source metadata;
|
||||
- leagues and teams, with an initial focus on European leagues;
|
||||
- one active season;
|
||||
- per-season averages, totals, and advanced metrics;
|
||||
- per-game logs for best and worst performance views.
|
||||
|
||||
The Angular dashboard applies filters only when the user refreshes the result set, then supports local stat sorting, summary metrics, and an open/close profile panel on desktop.
|
||||
|
||||
## External Integrations
|
||||
|
||||
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
|
||||
|
||||
- `docs/adr/0001-bootstrap-stack-and-data-boundaries.md`
|
||||
|
||||
+32
-11
@@ -1,15 +1,36 @@
|
||||
# Deployment
|
||||
|
||||
Describe how this project is deployed.
|
||||
The MVP supports local Docker Compose deployment only.
|
||||
|
||||
Include:
|
||||
## Local Environment
|
||||
|
||||
- environments;
|
||||
- Docker/Compose usage;
|
||||
- required configuration;
|
||||
- secrets handling;
|
||||
- exposed ports;
|
||||
- volumes;
|
||||
- networks;
|
||||
- deployment commands;
|
||||
- rollback procedure.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Initialize the database:
|
||||
|
||||
```bash
|
||||
docker compose run --rm backend python manage.py migrate
|
||||
docker compose run --rm backend python manage.py seed_demo_data
|
||||
docker compose run --rm backend python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## Exposed Ports
|
||||
|
||||
- `8000`: Django API and admin.
|
||||
- `4200`: Angular development server.
|
||||
|
||||
PostgreSQL is not published to the host.
|
||||
|
||||
During local development, the Angular dev server proxies `/api`, `/api-auth`, and `/admin` to the backend container.
|
||||
|
||||
## Volumes
|
||||
|
||||
- `postgres_data`: PostgreSQL data.
|
||||
- `frontend_node_modules`: frontend dependencies inside Docker.
|
||||
|
||||
## Rollback
|
||||
|
||||
For code rollback, revert the relevant Git commit and rebuild the Compose services. For local data rollback, restore a database backup or remove the `postgres_data` volume if disposable demo data is acceptable.
|
||||
|
||||
+35
-9
@@ -1,13 +1,39 @@
|
||||
# Operations
|
||||
|
||||
Describe operational procedures.
|
||||
## Startup
|
||||
|
||||
Include:
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
- startup and shutdown;
|
||||
- health checks;
|
||||
- logs;
|
||||
- monitoring;
|
||||
- backup and restore;
|
||||
- routine maintenance;
|
||||
- known operational risks.
|
||||
## Shutdown
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
|
||||
The PostgreSQL container has a `pg_isready` healthcheck. Backend and frontend smoke checks are manual in the MVP:
|
||||
|
||||
- backend: open `http://localhost:8000/admin/`;
|
||||
- frontend: open `http://localhost:4200/`.
|
||||
|
||||
## Logs
|
||||
|
||||
```bash
|
||||
docker compose logs backend
|
||||
docker compose logs frontend
|
||||
docker compose logs db
|
||||
```
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
No automated backup is configured for the MVP. Use `pg_dump` from the database container before preserving real scouting data.
|
||||
|
||||
## Known Operational Risks
|
||||
|
||||
- The frontend uses the Angular development server and is not production hardened.
|
||||
- Role-specific permissions are modeled but not yet enforced per action.
|
||||
- External data ingestion is intentionally not automated yet.
|
||||
- The Angular development cache is configured under `/tmp/angular-cache` inside the container so the non-root frontend user does not need write access to the bind-mounted source tree.
|
||||
|
||||
+26
-6
@@ -1,19 +1,39 @@
|
||||
# Runbook
|
||||
|
||||
Operational runbook for this project.
|
||||
|
||||
## Common tasks
|
||||
|
||||
Document routine operational tasks here.
|
||||
Apply migrations:
|
||||
|
||||
```bash
|
||||
docker compose run --rm backend python manage.py migrate
|
||||
```
|
||||
|
||||
Seed demo data:
|
||||
|
||||
```bash
|
||||
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
|
||||
docker compose run --rm backend python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Document known issues, symptoms, checks, and remediation steps.
|
||||
- If the frontend shows an authentication error, sign in through `http://localhost:8000/admin/` or DRF login first.
|
||||
- If the frontend serves a blank page, check `docker compose logs frontend`. Cache permission errors should be resolved by the configured `/tmp/angular-cache` path; restart with `docker compose up -d frontend`.
|
||||
- If the frontend loads but player data does not, confirm that `frontend/proxy.conf.json` is active and restart the frontend container.
|
||||
- If backend startup fails, check database readiness with `docker compose logs db`.
|
||||
- If dependencies change, rebuild with `docker compose build --no-cache backend frontend`.
|
||||
|
||||
## Rollback
|
||||
|
||||
Document rollback procedures here.
|
||||
Revert the Git commit containing the change and rebuild containers. If local database state must also be reverted, restore a database dump or recreate the `postgres_data` volume for disposable data.
|
||||
|
||||
## Emergency contacts
|
||||
|
||||
Document project-specific escalation paths if appropriate.
|
||||
Not configured for the local MVP.
|
||||
|
||||
+30
-12
@@ -1,16 +1,34 @@
|
||||
# Security
|
||||
|
||||
Describe security assumptions and controls.
|
||||
HoopScout is initially intended for local or restricted-network use by a small private group.
|
||||
|
||||
Include:
|
||||
## Authentication and Authorization
|
||||
|
||||
- authentication;
|
||||
- authorization;
|
||||
- network exposure;
|
||||
- TLS/certificates;
|
||||
- secrets management;
|
||||
- logging of sensitive data;
|
||||
- container privileges;
|
||||
- filesystem permissions;
|
||||
- dependency management;
|
||||
- relevant ADRs.
|
||||
- API endpoints require an authenticated Django user.
|
||||
- Django admin is enabled for controlled data management.
|
||||
- Users have a profile role: `admin`, `scout`, or `viewer`.
|
||||
- Role-specific authorization is not enforced beyond authentication in the MVP.
|
||||
|
||||
## Network Exposure
|
||||
|
||||
Local Compose exposes:
|
||||
|
||||
- backend on `8000`;
|
||||
- frontend on `4200`;
|
||||
- PostgreSQL only inside the Compose network.
|
||||
|
||||
## Secrets
|
||||
|
||||
`.env.example` contains placeholders only. Real local values must be stored in `.env`, which is ignored by Git.
|
||||
|
||||
## Containers
|
||||
|
||||
Backend and frontend containers run as non-root users. PostgreSQL uses the official image defaults and a named volume.
|
||||
|
||||
## Data Sources
|
||||
|
||||
The repository does not include credentials, scraping logic, or copied external datasets. RealGM, Proballers, and other provider data must be integrated only through authorized APIs or a documented compliant import process.
|
||||
|
||||
## Known Dependency Findings
|
||||
|
||||
`npm audit` reports moderate vulnerabilities through `webpack-dev-server -> sockjs -> uuid` in the Angular development toolchain, with no available fix at the time of implementation. The dev server is intended for local restricted use only and must not be exposed publicly.
|
||||
|
||||
+12
-12
@@ -1,23 +1,23 @@
|
||||
# Testing
|
||||
|
||||
Describe how tests are executed.
|
||||
All tests must run inside Docker containers.
|
||||
|
||||
All tests should run inside Docker containers.
|
||||
## Canonical Test Command
|
||||
|
||||
## Canonical test command
|
||||
Run these commands from the repository root:
|
||||
|
||||
```bash
|
||||
CHANGE_ME
|
||||
docker compose run --rm backend ruff check .
|
||||
docker compose run --rm backend pytest
|
||||
docker compose run --rm frontend npm test
|
||||
docker compose config
|
||||
```
|
||||
|
||||
## Test categories
|
||||
|
||||
Describe applicable categories:
|
||||
- Backend API tests use `pytest` and `pytest-django`.
|
||||
- Backend linting uses `ruff`.
|
||||
- Frontend unit tests use `vitest`.
|
||||
- Docker Compose validation uses `docker compose config`.
|
||||
|
||||
- unit tests;
|
||||
- integration tests;
|
||||
- linting;
|
||||
- formatting checks;
|
||||
- Ansible syntax checks;
|
||||
- Docker/Compose validation;
|
||||
- smoke tests.
|
||||
The initial TDD coverage verifies authentication requirements, player filtering, default ranking, profile performance summaries, user roles, and frontend filter serialization.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.angular
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM node:22.16.0-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 4200
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"cache": {
|
||||
"path": "/tmp/angular-cache"
|
||||
}
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"hoopscout-frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/hoopscout-frontend",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [],
|
||||
"styles": ["src/styles.css"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "600kB",
|
||||
"maximumError": "1MB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "hoopscout-frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "hoopscout-frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+11995
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "hoopscout-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "ng serve --host 0.0.0.0 --port 4200 --proxy-config proxy.conf.json",
|
||||
"build": "ng build",
|
||||
"test": "tsx --import ./src/test-setup.ts --test \"src/**/*.spec.ts\"",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "21.2.14",
|
||||
"@angular/common": "21.2.14",
|
||||
"@angular/compiler": "21.2.14",
|
||||
"@angular/core": "21.2.14",
|
||||
"@angular/forms": "21.2.14",
|
||||
"@angular/platform-browser": "21.2.14",
|
||||
"@angular/router": "21.2.14",
|
||||
"rxjs": "7.8.2",
|
||||
"tslib": "2.8.1",
|
||||
"zone.js": "0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "21.2.14",
|
||||
"@angular/cli": "21.2.14",
|
||||
"@angular/compiler-cli": "21.2.14",
|
||||
"@types/node": "22.15.30",
|
||||
"tsx": "4.20.3",
|
||||
"typescript": "5.9.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://backend:8000",
|
||||
"secure": false,
|
||||
"changeOrigin": false
|
||||
},
|
||||
"/api-auth": {
|
||||
"target": "http://backend:8000",
|
||||
"secure": false,
|
||||
"changeOrigin": false
|
||||
},
|
||||
"/admin": {
|
||||
"target": "http://backend:8000",
|
||||
"secure": false,
|
||||
"changeOrigin": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { buildPlayerSearchParams } from './player-api.service';
|
||||
|
||||
describe('buildPlayerSearchParams', () => {
|
||||
it('keeps only populated filters and maps stat ranges to API lookups', () => {
|
||||
const params = buildPlayerSearchParams({
|
||||
q: 'luca',
|
||||
position: 'PG',
|
||||
role: '',
|
||||
league: 'LBA',
|
||||
minPoints: 15,
|
||||
minAssists: 5,
|
||||
minRebounds: null,
|
||||
minEfficiency: 18,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
params.toString(),
|
||||
'q=luca&position=PG&league=LBA&points_per_game__gte=15&assists_per_game__gte=5&efficiency_rating__gte=18',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { PlayerFilters, PlayerSearchResponse } from '../models';
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
export function buildPlayerSearchParams(filters: PlayerFilters): HttpParams {
|
||||
let params = new HttpParams();
|
||||
|
||||
const textFilters: Array<[string, string]> = [
|
||||
['q', filters.q],
|
||||
['position', filters.position],
|
||||
['role', filters.role],
|
||||
['league', filters.league],
|
||||
];
|
||||
|
||||
for (const [key, value] of textFilters) {
|
||||
if (value.trim().length > 0) {
|
||||
params = params.set(key, value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
const statFilters: Array<[string, number | null]> = [
|
||||
['points_per_game__gte', filters.minPoints],
|
||||
['assists_per_game__gte', filters.minAssists],
|
||||
['rebounds_per_game__gte', filters.minRebounds],
|
||||
['efficiency_rating__gte', filters.minEfficiency],
|
||||
];
|
||||
|
||||
for (const [key, value] of statFilters) {
|
||||
if (value !== null && Number.isFinite(value)) {
|
||||
params = params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlayerApiService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
searchPlayers(filters: PlayerFilters): Observable<PlayerSearchResponse> {
|
||||
return this.http.get<PlayerSearchResponse>(`${API_BASE_URL}/players/`, {
|
||||
params: buildPlayerSearchParams(filters),
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
.shell {
|
||||
width: min(1560px, calc(100vw - 28px));
|
||||
margin: 0 auto;
|
||||
padding: 18px 0 28px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.4rem;
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.scoreboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(112px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scoreboard div {
|
||||
min-width: 112px;
|
||||
padding: 12px 14px;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--accent-strong), #253b55);
|
||||
border-radius: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.scoreboard span {
|
||||
display: block;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.scoreboard small {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
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: 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;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
color: var(--ink);
|
||||
background: #fbfcfa;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
border-color: var(--accent);
|
||||
outline: 2px solid rgba(23, 107, 100, 0.16);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.primary {
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
color: var(--accent-strong);
|
||||
background: #e7eeeb;
|
||||
}
|
||||
|
||||
.compact {
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
color: var(--accent-strong);
|
||||
background: #e8efec;
|
||||
border: 1px solid #cad8d2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort.active {
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin: 10px 0 0;
|
||||
padding: 12px 14px;
|
||||
color: #7a1d17;
|
||||
background: #fff1ee;
|
||||
border: 1px solid #f2bbb2;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
margin-top: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.workspace.detail-open {
|
||||
grid-template-columns: minmax(0, 1fr) 390px;
|
||||
}
|
||||
|
||||
.table-wrap,
|
||||
.detail {
|
||||
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;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
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;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover,
|
||||
tbody tr.selected {
|
||||
background: #eef6f3;
|
||||
}
|
||||
|
||||
td strong,
|
||||
td small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
td small {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 28px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role {
|
||||
margin: 6px 0 0;
|
||||
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;
|
||||
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;
|
||||
padding: 12px;
|
||||
background: #f8faf7;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
dt,
|
||||
.metric-grid span {
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 6px 0 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-grid strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--accent-strong);
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.filters {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.search-field {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace.detail-open {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.detail {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell {
|
||||
width: min(100vw - 20px, 1500px);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.scoreboard {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.scoreboard div {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 820px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.scoreboard,
|
||||
.filters,
|
||||
.bio-grid,
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-field,
|
||||
.actions {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Private basketball scouting</p>
|
||||
<h1>HoopScout</h1>
|
||||
</div>
|
||||
<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 class="search-field">
|
||||
Search
|
||||
<input
|
||||
type="search"
|
||||
[(ngModel)]="filters.q"
|
||||
placeholder="Name, role, team, nationality"
|
||||
>
|
||||
</label>
|
||||
<label>
|
||||
Position
|
||||
<select [(ngModel)]="filters.position">
|
||||
<option value="">Any</option>
|
||||
<option *ngFor="let position of positions" [value]="position">{{ position }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Role
|
||||
<select [(ngModel)]="filters.role">
|
||||
<option value="">Any</option>
|
||||
<option *ngFor="let role of roles" [value]="role">{{ role }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
League
|
||||
<select [(ngModel)]="filters.league">
|
||||
<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">
|
||||
</label>
|
||||
<label>
|
||||
Min APG
|
||||
<input type="number" [(ngModel)]="filters.minAssists" min="0" step="0.1">
|
||||
</label>
|
||||
<label>
|
||||
Min RPG
|
||||
<input type="number" [(ngModel)]="filters.minRebounds" min="0" step="0.1">
|
||||
</label>
|
||||
<label>
|
||||
Min EFF
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="filters.minEfficiency"
|
||||
min="0"
|
||||
step="0.1"
|
||||
>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="button" class="primary" (click)="search()">Refresh</button>
|
||||
<button type="button" class="secondary" (click)="clearFilters()">Reset</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p *ngIf="errorMessage" class="alert">{{ errorMessage }}</p>
|
||||
|
||||
<section class="workspace" [class.detail-open]="selectedPlayer" aria-label="Scouting workspace">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Pos</th>
|
||||
<th>League</th>
|
||||
<th>Team</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>
|
||||
<tr
|
||||
*ngFor="let player of players"
|
||||
[class.selected]="selectedPlayer?.id === player.id"
|
||||
(click)="selectPlayer(player)"
|
||||
>
|
||||
<td>
|
||||
<strong>{{ player.name }}</strong>
|
||||
<small>{{ player.nationality || '-' }} · {{ player.height_cm || '-' }} cm · {{ player.weight_kg || '-' }} kg</small>
|
||||
</td>
|
||||
<td>{{ player.position }}</td>
|
||||
<td>{{ player.league?.code || '-' }}</td>
|
||||
<td>{{ player.team?.name || '-' }}</td>
|
||||
<td>{{ statValue(player, 'points_per_game') }}</td>
|
||||
<td>{{ statValue(player, 'assists_per_game') }}</td>
|
||||
<td>{{ statValue(player, 'rebounds_per_game') }}</td>
|
||||
<td>{{ statValue(player, 'efficiency_rating') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div *ngIf="!loading && players.length === 0" class="empty">No players match the current filters.</div>
|
||||
<div *ngIf="showLoadingPlaceholder" class="empty">Loading scouting board...</div>
|
||||
</div>
|
||||
|
||||
<aside class="detail" *ngIf="selectedPlayer">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<p class="eyebrow">Selected profile</p>
|
||||
<h2>{{ selectedPlayer.name }}</h2>
|
||||
<p class="role">{{ selectedPlayer.position }} · {{ selectedPlayer.role || 'Role pending' }}</p>
|
||||
</div>
|
||||
<button type="button" class="secondary compact" (click)="returnToResults()">Back to results</button>
|
||||
</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>
|
||||
<dd>{{ selectedPlayer.league?.name || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Team</dt>
|
||||
<dd>{{ selectedPlayer.team?.name || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Born</dt>
|
||||
<dd>{{ selectedPlayer.birth_year || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{{ selectedPlayer.height_cm || '-' }} cm / {{ selectedPlayer.weight_kg || '-' }} kg</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="metric-grid">
|
||||
<div>
|
||||
<span>PPG</span>
|
||||
<strong>{{ statValue(selectedPlayer, 'points_per_game') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>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>
|
||||
</div>
|
||||
<div>
|
||||
<span>USG%</span>
|
||||
<strong>{{ statValue(selectedPlayer, 'usage_percentage') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Games</span>
|
||||
<strong>{{ statValue(selectedPlayer, 'games_played') }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
@@ -0,0 +1,157 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { PlayerApiService } from './api/player-api.service';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
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: samplePlayers.length,
|
||||
results: samplePlayers,
|
||||
}),
|
||||
} as unknown as PlayerApiService;
|
||||
const component = new AppComponent(api);
|
||||
|
||||
component.search();
|
||||
|
||||
assert.equal(component.players.length, 2);
|
||||
assert.equal(component.players[0].name, 'Luca Marini');
|
||||
assert.equal(component.resultCount, 2);
|
||||
assert.equal(component.averagePoints, '14.00');
|
||||
assert.equal(component.topEfficiencyPlayer?.name, 'Luca Marini');
|
||||
assert.equal(component.selectedPlayer, null);
|
||||
});
|
||||
|
||||
it('does not apply changed filters until refresh is requested', () => {
|
||||
let calls = 0;
|
||||
let requestedLeague = '';
|
||||
const api = {
|
||||
searchPlayers: (filters: { league: string }) => {
|
||||
calls += 1;
|
||||
requestedLeague = filters.league;
|
||||
return of({ count: 0, results: [] });
|
||||
},
|
||||
} as unknown as PlayerApiService;
|
||||
const component = new AppComponent(api);
|
||||
|
||||
component.filters.league = 'LBA';
|
||||
component.filters.league = 'ABA';
|
||||
|
||||
assert.equal(calls, 0);
|
||||
|
||||
component.search();
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(requestedLeague, 'ABA');
|
||||
});
|
||||
|
||||
it('exposes role options and sends the selected role as a filter', () => {
|
||||
let requestedRole = '';
|
||||
const api = {
|
||||
searchPlayers: (filters: { role: string }) => {
|
||||
requestedRole = filters.role;
|
||||
return of({ count: 0, results: [] });
|
||||
},
|
||||
} as unknown as PlayerApiService;
|
||||
const component = new AppComponent(api);
|
||||
|
||||
assert.ok(component.roles.includes('3 and D wing'));
|
||||
|
||||
component.filters.role = '3 and D wing';
|
||||
component.search();
|
||||
|
||||
assert.equal(requestedRole, '3 and D wing');
|
||||
});
|
||||
|
||||
it('shows the loading placeholder only before results exist', () => {
|
||||
const api = {
|
||||
searchPlayers: () => of({ count: samplePlayers.length, results: samplePlayers }),
|
||||
} as unknown as PlayerApiService;
|
||||
const component = new AppComponent(api);
|
||||
|
||||
component.loading = true;
|
||||
assert.equal(component.showLoadingPlaceholder, true);
|
||||
|
||||
component.players = samplePlayers;
|
||||
assert.equal(component.showLoadingPlaceholder, false);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('opens and closes a player profile without losing the filtered list', () => {
|
||||
const api = {
|
||||
searchPlayers: () => of({ count: samplePlayers.length, results: samplePlayers }),
|
||||
} as unknown as PlayerApiService;
|
||||
const component = new AppComponent(api);
|
||||
|
||||
component.search();
|
||||
component.selectPlayer(samplePlayers[0]);
|
||||
component.returnToResults();
|
||||
|
||||
assert.equal(component.selectedPlayer, null);
|
||||
assert.equal(component.players.length, 2);
|
||||
assert.equal(component.resultCount, 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { PlayerApiService } from './api/player-api.service';
|
||||
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',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.css',
|
||||
})
|
||||
export class AppComponent {
|
||||
readonly positions = ['PG', 'SG', 'SF', 'PF', 'C'];
|
||||
readonly leagues = ['LBA', 'ACB', 'ABA', 'BBL', 'BSL', 'LNB', 'ISBL', 'NBL', 'NZNBL'];
|
||||
readonly roles = [
|
||||
'3 and D wing',
|
||||
'Change-of-pace guard',
|
||||
'Connector wing',
|
||||
'Defensive playmaker',
|
||||
'Drive and kick guard',
|
||||
'Face-up forward',
|
||||
'Glass cleaner',
|
||||
'Low-post finisher',
|
||||
'Movement shooter',
|
||||
'Off-screen scorer',
|
||||
'Paint anchor',
|
||||
'Paint touch guard',
|
||||
'Pick and roll creator',
|
||||
'Pressure guard',
|
||||
'Primary ball handler',
|
||||
'Pull-up shooter',
|
||||
'Rim protector',
|
||||
'Roll man',
|
||||
'Secondary creator',
|
||||
'Short-roll passer',
|
||||
'Slashing wing',
|
||||
'Stretch four',
|
||||
'Switch defender',
|
||||
'Tempo guard',
|
||||
'Transition wing',
|
||||
'Vertical spacer',
|
||||
];
|
||||
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: '',
|
||||
position: '',
|
||||
role: '',
|
||||
league: '',
|
||||
minPoints: null,
|
||||
minAssists: null,
|
||||
minRebounds: null,
|
||||
minEfficiency: null,
|
||||
};
|
||||
players: PlayerSummary[] = [];
|
||||
selectedPlayer: PlayerSummary | null = null;
|
||||
resultCount = 0;
|
||||
loading = false;
|
||||
errorMessage = '';
|
||||
activeSort: SortKey = 'efficiency_rating';
|
||||
|
||||
constructor(private readonly playerApi: PlayerApiService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.search();
|
||||
}
|
||||
|
||||
search(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
this.playerApi.searchPlayers(this.filters).subscribe({
|
||||
next: (response) => {
|
||||
const players = this.sortPlayers(response.results);
|
||||
this.players = players;
|
||||
this.resultCount = response.count;
|
||||
this.selectedPlayer = this.matchSelectedPlayer(players);
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.errorMessage = 'Sign in is required or the scouting API is unavailable.';
|
||||
this.loading = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filters = {
|
||||
q: '',
|
||||
position: '',
|
||||
role: '',
|
||||
league: '',
|
||||
minPoints: null,
|
||||
minAssists: null,
|
||||
minRebounds: null,
|
||||
minEfficiency: null,
|
||||
};
|
||||
this.search();
|
||||
}
|
||||
|
||||
sortBy(key: SortKey): void {
|
||||
this.activeSort = key;
|
||||
this.players = this.sortPlayers(this.players);
|
||||
this.selectedPlayer = this.matchSelectedPlayer(this.players);
|
||||
}
|
||||
|
||||
selectPlayer(player: PlayerSummary): void {
|
||||
this.selectedPlayer = player;
|
||||
}
|
||||
|
||||
returnToResults(): void {
|
||||
this.selectedPlayer = null;
|
||||
}
|
||||
|
||||
statValue(player: PlayerSummary, key: keyof NonNullable<PlayerSummary['stats']>): string {
|
||||
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 showLoadingPlaceholder(): boolean {
|
||||
return this.loading && this.players.length === 0;
|
||||
}
|
||||
|
||||
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 matchSelectedPlayer(players: PlayerSummary[]): PlayerSummary | null {
|
||||
if (!this.selectedPlayer) {
|
||||
return null;
|
||||
}
|
||||
return players.find((player) => player.id === this.selectedPlayer?.id) ?? null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export interface League {
|
||||
name: string;
|
||||
code: string;
|
||||
region: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
name: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface PlayerStats {
|
||||
games_played: number;
|
||||
minutes_per_game: string;
|
||||
points_per_game: string;
|
||||
assists_per_game: string;
|
||||
rebounds_per_game: string;
|
||||
efficiency_rating: string;
|
||||
true_shooting_percentage: string;
|
||||
usage_percentage: string;
|
||||
}
|
||||
|
||||
export interface PlayerSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
position: string;
|
||||
role: string;
|
||||
birth_year: number | null;
|
||||
height_cm: number | null;
|
||||
weight_kg: number | null;
|
||||
nationality: string;
|
||||
league: League | null;
|
||||
team: Team | null;
|
||||
stats: PlayerStats | null;
|
||||
}
|
||||
|
||||
export interface PlayerSearchResponse {
|
||||
count: number;
|
||||
results: PlayerSummary[];
|
||||
}
|
||||
|
||||
export interface PlayerFilters {
|
||||
q: string;
|
||||
position: string;
|
||||
role: string;
|
||||
league: string;
|
||||
minPoints: number | null;
|
||||
minAssists: number | null;
|
||||
minRebounds: number | null;
|
||||
minEfficiency: number | null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>HoopScout</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { provideHttpClient, withXsrfConfiguration } from '@angular/common/http';
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
provideAnimations(),
|
||||
provideHttpClient(
|
||||
withXsrfConfiguration({
|
||||
cookieName: 'csrftoken',
|
||||
headerName: 'X-CSRFToken',
|
||||
}),
|
||||
),
|
||||
],
|
||||
}).catch((error) => console.error(error));
|
||||
@@ -0,0 +1,30 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f3f5f1;
|
||||
--ink: #17211f;
|
||||
--muted: #66706c;
|
||||
--panel: #ffffff;
|
||||
--line: #d9dfd9;
|
||||
--accent: #176b64;
|
||||
--accent-strong: #0d4d48;
|
||||
--gold: #bd8b2f;
|
||||
--red: #a4473f;
|
||||
--shadow: 0 18px 50px rgba(25, 38, 35, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@angular/compiler';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./dist/out-tsc",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"downlevelIteration": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022", "dom"]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user