merge: bootstrap HoopScout scouting app

This commit is contained in:
bisco
2026-06-03 21:37:31 +02:00
52 changed files with 14505 additions and 126 deletions
+7 -6
View File
@@ -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:
+8
View File
@@ -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
View File
@@ -0,0 +1,12 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
htmlcov/
.coverage
node_modules/
dist/
.angular/
npm-debug.log*
+30 -61
View File
@@ -1,73 +1,42 @@
# 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.
+5
View File
@@ -0,0 +1,5 @@
.venv
__pycache__
.pytest_cache
.ruff_cache
*.pyc
+20
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
+7
View File
@@ -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()
+91
View File
@@ -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": 25,
}
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(",")
+17
View File
@@ -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")),
]
+7
View File
@@ -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()
+14
View File
@@ -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()
+30
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
+59
View File
@@ -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")
+9
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,156 @@
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],
}
demo_rows = [
{
"first_name": "Luca",
"last_name": "Marini",
"position": "PG",
"role": "Primary ball handler",
"birth_year": 2001,
"height_cm": 190,
"weight_kg": 86,
"nationality": "Italy",
"team": "Milano",
"league": "LBA",
"points": 16.8,
"assists": 6.2,
"rebounds": 3.8,
"efficiency": 20.5,
"ts": 59.2,
"usage": 25.0,
},
{
"first_name": "Mateo",
"last_name": "Santos",
"position": "SF",
"role": "3 and D wing",
"birth_year": 1999,
"height_cm": 201,
"weight_kg": 96,
"nationality": "Spain",
"team": "Madrid",
"league": "ACB",
"points": 11.2,
"assists": 2.1,
"rebounds": 5.5,
"efficiency": 13.7,
"ts": 55.8,
"usage": 17.5,
},
{
"first_name": "Jonas",
"last_name": "Keller",
"position": "C",
"role": "Roll man",
"birth_year": 2000,
"height_cm": 211,
"weight_kg": 109,
"nationality": "Germany",
"team": "Berlin",
"league": "BBL",
"points": 13.1,
"assists": 1.4,
"rebounds": 8.6,
"efficiency": 18.9,
"ts": 63.1,
"usage": 19.0,
},
]
for row in demo_rows:
league = leagues[row["league"]]
team, _ = Team.objects.update_or_create(
name=row["team"],
league=league,
defaults={"country": league.country},
)
player, _ = Player.objects.update_or_create(
first_name=row["first_name"],
last_name=row["last_name"],
birth_year=row["birth_year"],
nationality=row["nationality"],
defaults={
"position": row["position"],
"role": row["role"],
"height_cm": row["height_cm"],
"weight_kg": row["weight_kg"],
"current_team": team,
"external_source": "synthetic",
},
)
PlayerSeasonStat.objects.update_or_create(
player=player,
team=team,
league=league,
season=season,
defaults={
"games_played": 28,
"minutes_per_game": 27.5,
"points_per_game": row["points"],
"assists_per_game": row["assists"],
"rebounds_per_game": row["rebounds"],
"steals_per_game": 1.0,
"blocks_per_game": 0.4,
"turnovers_per_game": 1.8,
"field_goal_percentage": 48.0,
"three_point_percentage": 37.5,
"free_throw_percentage": 81.0,
"efficiency_rating": row["efficiency"],
"true_shooting_percentage": row["ts"],
"usage_percentage": row["usage"],
"total_points": int(row["points"] * 28),
"total_assists": int(row["assists"] * 28),
"total_rebounds": int(row["rebounds"] * 28),
},
)
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(row["points"] + 10),
"assists": int(row["assists"] + 3),
"rebounds": int(row["rebounds"] + 2),
"efficiency_rating": row["efficiency"] + 10,
},
)
self.stdout.write(self.style.SUCCESS("Seeded synthetic HoopScout demo data."))
+242
View File
@@ -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",
),
],
},
),
]
+1
View File
@@ -0,0 +1 @@
+152
View File
@@ -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}"
+113
View File
@@ -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
+11
View File
@@ -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)
+233
View File
@@ -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"}
+128
View File
@@ -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})
+63
View File
@@ -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.
+26 -9
View File
@@ -1,13 +1,30 @@
# 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.
## External Integrations
No automated external ingestion is included in the MVP. Demo data is synthetic. RealGM, Proballers, or similar data providers require a later authorized API/import decision before real data is collected.
## Relevant ADRs
- `docs/adr/0001-bootstrap-stack-and-data-boundaries.md`
+30 -11
View File
@@ -1,15 +1,34 @@
# 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.
## 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.
+34 -9
View File
@@ -1,13 +1,38 @@
# 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.
+22 -6
View File
@@ -1,19 +1,35 @@
# 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
```
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 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
View File
@@ -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
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.angular
npm-debug.log*
+16
View File
@@ -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"]
+58
View File
@@ -0,0 +1,58 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"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"
}
}
}
}
}
+11995
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "hoopscout-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "ng serve --host 0.0.0.0 --port 4200",
"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,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,
});
}
}
+284
View File
@@ -0,0 +1,284 @@
.shell {
width: min(1500px, calc(100vw - 32px));
margin: 0 auto;
padding: 24px 0 32px;
}
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
}
.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: clamp(2rem, 4vw, 4rem);
line-height: 0.95;
}
h2 {
font-size: 1.6rem;
}
.status {
min-width: 112px;
padding: 12px 16px;
color: white;
background: var(--accent-strong);
border-radius: 8px;
text-align: right;
}
.status span {
display: block;
font-size: 1.6rem;
font-weight: 800;
}
.status small {
color: rgba(255, 255, 255, 0.75);
}
.filters {
display: grid;
grid-template-columns: minmax(220px, 1.7fr) repeat(7, minmax(110px, 1fr)) auto;
gap: 10px;
align-items: end;
padding: 14px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
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;
}
.alert {
margin: 14px 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) 360px;
gap: 16px;
margin-top: 16px;
align-items: start;
}
.table-wrap,
.detail {
overflow: hidden;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
padding: 13px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: middle;
}
th {
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;
}
.role {
margin: 6px 0 0;
color: var(--muted);
}
.bio-grid,
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin: 0;
}
.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.5rem;
}
@media (max-width: 1200px) {
.filters {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.actions {
grid-column: span 4;
}
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.shell {
width: min(100vw - 20px, 1500px);
padding-top: 14px;
}
.topbar {
align-items: stretch;
flex-direction: column;
}
.status {
text-align: left;
}
.filters {
grid-template-columns: 1fr 1fr;
}
.actions {
grid-column: span 2;
}
.table-wrap {
overflow-x: auto;
}
table {
min-width: 820px;
}
}
+141
View File
@@ -0,0 +1,141 @@
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">Private basketball scouting</p>
<h1>HoopScout</h1>
</div>
<div class="status">
<span>{{ resultCount }}</span>
<small>matches</small>
</div>
</header>
<section class="filters" aria-label="Player filters">
<label>
Search
<input type="search" [(ngModel)]="filters.q" placeholder="Name, role, team, nationality">
</label>
<label>
Position
<select [(ngModel)]="filters.position">
<option *ngFor="let position of positions" [value]="position">{{ position || 'Any' }}</option>
</select>
</label>
<label>
Role
<input type="text" [(ngModel)]="filters.role" placeholder="3 and D, handler, rim runner">
</label>
<label>
League
<select [(ngModel)]="filters.league">
<option *ngFor="let league of leagues" [value]="league">{{ league || 'Any' }}</option>
</select>
</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()">Search</button>
<button type="button" class="secondary" (click)="clearFilters()">Reset</button>
</div>
</section>
<p *ngIf="errorMessage" class="alert">{{ errorMessage }}</p>
<section class="workspace" aria-label="Scouting workspace">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Player</th>
<th>Pos</th>
<th>League</th>
<th>Team</th>
<th>PPG</th>
<th>APG</th>
<th>RPG</th>
<th>EFF</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="loading" class="empty">Loading scouting board...</div>
</div>
<aside class="detail" *ngIf="selectedPlayer">
<div>
<p class="eyebrow">Selected profile</p>
<h2>{{ selectedPlayer.name }}</h2>
<p class="role">{{ selectedPlayer.position }} · {{ selectedPlayer.role || 'Role pending' }}</p>
</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>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>
+49
View File
@@ -0,0 +1,49 @@
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', () => {
it('loads players through the API service', () => {
const api = {
searchPlayers: () =>
of({
count: 1,
results: [
{
id: 1,
name: 'Luca Marini',
position: 'PG',
role: 'Primary ball handler',
birth_year: 2001,
height_cm: 190,
weight_kg: 86,
nationality: 'Italy',
league: { name: 'Lega Basket Serie A', code: 'LBA', region: 'Europe', country: 'Italy' },
team: { name: 'Milano', country: 'Italy' },
stats: {
games_played: 28,
minutes_per_game: '29.40',
points_per_game: '16.80',
assists_per_game: '6.20',
rebounds_per_game: '3.80',
efficiency_rating: '20.50',
true_shooting_percentage: '59.20',
usage_percentage: '25.00',
},
},
],
}),
} as unknown as PlayerApiService;
const component = new AppComponent(api);
component.search();
assert.equal(component.players.length, 1);
assert.equal(component.players[0].name, 'Luca Marini');
assert.equal(component.resultCount, 1);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { PlayerApiService } from './api/player-api.service';
import { PlayerFilters, PlayerSummary } from './models';
@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'];
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 = '';
constructor(private readonly playerApi: PlayerApiService) {}
ngOnInit(): void {
this.search();
}
search(): void {
this.loading = true;
this.errorMessage = '';
this.playerApi.searchPlayers(this.filters).subscribe({
next: (response) => {
this.players = response.results;
this.resultCount = response.count;
this.selectedPlayer = this.players[0] ?? null;
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();
}
selectPlayer(player: PlayerSummary): void {
this.selectedPlayer = player;
}
statValue(player: PlayerSummary, key: keyof NonNullable<PlayerSummary['stats']>): string {
const value = player.stats?.[key];
return value === undefined || value === null ? '-' : String(value);
}
}
+52
View File
@@ -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;
}
+12
View File
@@ -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>
+17
View File
@@ -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));
+30
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
import '@angular/compiler';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}
+29
View File
@@ -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"]
}