From dac63f91480184b675903db234d26517526f229f Mon Sep 17 00:00:00 2001 From: Alfredo Di Stasio Date: Thu, 12 Mar 2026 16:37:02 +0100 Subject: [PATCH] Align balldontlie OpenAPI integration and clarify search metric semantics --- .env.example | 4 +- README.md | 8 +- apps/api/views.py | 7 + apps/players/forms.py | 8 +- apps/players/services/search.py | 11 + apps/players/views.py | 9 +- .../adapters/balldontlie_provider.py | 57 +- apps/providers/clients/balldontlie.py | 8 - config/settings/base.py | 4 +- docs/provider-normalization.md | 2 + static/css/main.css | 1187 ++++++++++++++++- templates/players/partials/results.html | 13 +- tests/test_api.py | 51 + tests/test_players_filters_advanced.py | 204 +++ tests/test_players_views.py | 49 + tests/test_provider_balldontlie.py | 22 +- 16 files changed, 1562 insertions(+), 82 deletions(-) diff --git a/.env.example b/.env.example index 7fd0a5f..0ca5098 100644 --- a/.env.example +++ b/.env.example @@ -59,14 +59,12 @@ PROVIDER_MVP_DATA_FILE=/app/apps/providers/data/mvp_provider.json PROVIDER_REQUEST_RETRIES=3 PROVIDER_REQUEST_RETRY_SLEEP=1 PROVIDER_HTTP_TIMEOUT_SECONDS=10 -PROVIDER_BALLDONTLIE_BASE_URL=https://api.balldontlie.io/nba/v1 +PROVIDER_BALLDONTLIE_BASE_URL=https://api.balldontlie.io PROVIDER_BALLDONTLIE_API_KEY= # NBA-centric MVP provider seasons to ingest (comma-separated years). PROVIDER_BALLDONTLIE_SEASONS=2024 PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT=5 PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE=100 -PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT=5 -PROVIDER_BALLDONTLIE_GAMES_PER_PAGE=100 PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT=10 PROVIDER_BALLDONTLIE_STATS_PER_PAGE=100 # When 0, a 401 on stats endpoint degrades to players/teams-only sync. diff --git a/README.md b/README.md index 974f1f0..8473d83 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,11 @@ Notes: - The server-rendered player search page (`/players/`) and read-only players API (`/api/players/`) use the same search form and ORM filter service. - Sorting/filter semantics are aligned across UI, HTMX partial refreshes, and API responses. -- Statistical metric annotations are always computed for UI result tables, and only computed for API requests when metric-based sorting is requested. +- Search result metrics in the UI table use **best eligible semantics**: + - each metric (Games, MPG, PPG, RPG, APG) is the maximum value across eligible player-season rows + - eligibility is scoped by the active season/team/competition/stat filters + - different displayed metrics for one player can come from different eligible rows +- Metric-based API sorting (`ppg_*`, `mpg_*`) uses the same best-eligible semantics as UI search. ## Docker Volumes and Persistence @@ -325,7 +329,7 @@ Provider backend is selected via environment variables: - `PROVIDER_DEFAULT_NAMESPACE` can override backend mapping explicitly The balldontlie adapter is NBA-centric and intended as MVP ingestion only. The provider abstraction remains ready for future multi-league providers (for example Sportradar or FIBA GDAP). -The adapter uses balldontlie getting-started query style (`/nba/v1`, cursor pagination, stats by `game_ids[]`). +The adapter follows the published balldontlie OpenAPI contract: server `https://api.balldontlie.io`, NBA endpoints under `/nba/v1/*`, cursor pagination via `meta.next_cursor`, and `stats` ingestion filtered by `seasons[]`. Some balldontlie plans do not include stats endpoints; set `PROVIDER_BALLDONTLIE_STATS_STRICT=0` (default) to ingest players/teams/seasons even when stats are unauthorized. Provider normalization details and explicit adapter assumptions are documented in [docs/provider-normalization.md](docs/provider-normalization.md). diff --git a/apps/api/views.py b/apps/api/views.py index cc66ea6..456b67b 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -38,6 +38,13 @@ class ReadOnlyBaseAPIView: class PlayerSearchApiView(ReadOnlyBaseAPIView, generics.ListAPIView): + """ + Read-only player search API. + + Metric sorts (`ppg_*`, `mpg_*`) follow the same best-eligible semantics as UI search: + max metric value across eligible player-season rows after applying search filters. + """ + serializer_class = PlayerListSerializer pagination_class = ApiPagination diff --git a/apps/players/forms.py b/apps/players/forms.py index ceafae1..f7a0cfe 100644 --- a/apps/players/forms.py +++ b/apps/players/forms.py @@ -13,10 +13,10 @@ class PlayerSearchForm(forms.Form): ("age_oldest", "Age (Oldest first)"), ("height_desc", "Height (Tallest first)"), ("height_asc", "Height (Shortest first)"), - ("ppg_desc", "Points per game (High to low)"), - ("ppg_asc", "Points per game (Low to high)"), - ("mpg_desc", "Minutes per game (High to low)"), - ("mpg_asc", "Minutes per game (Low to high)"), + ("ppg_desc", "Best eligible PPG (High to low)"), + ("ppg_asc", "Best eligible PPG (Low to high)"), + ("mpg_desc", "Best eligible MPG (High to low)"), + ("mpg_asc", "Best eligible MPG (Low to high)"), ) PAGE_SIZE_CHOICES = ((20, "20"), (50, "50"), (100, "100")) diff --git a/apps/players/services/search.py b/apps/players/services/search.py index 85d294e..31fcea8 100644 --- a/apps/players/services/search.py +++ b/apps/players/services/search.py @@ -20,6 +20,10 @@ from apps.players.models import Player from apps.stats.models import PlayerSeason METRIC_SORT_KEYS = {"ppg_desc", "ppg_asc", "mpg_desc", "mpg_asc"} +SEARCH_METRIC_SEMANTICS_TEXT = ( + "Search metrics are best eligible values per player (max per metric across eligible player-season rows). " + "With season/team/competition/stat filters, eligibility is scoped by those filters." +) def _years_ago_today(years: int) -> date: @@ -213,6 +217,13 @@ def filter_players(queryset, data: dict): def annotate_player_metrics(queryset, data: dict | None = None): + """ + Annotate player list metrics using best-eligible semantics. + + Each metric is computed as MAX over eligible player-season rows. This is intentionally + not a single-row projection; different displayed metrics for one player can come from + different eligible player-season rows. + """ data = data or {} context_filter = _build_metric_context_filter(data) diff --git a/apps/players/views.py b/apps/players/views.py index 930ec23..993e87c 100644 --- a/apps/players/views.py +++ b/apps/players/views.py @@ -8,7 +8,13 @@ from apps.stats.models import PlayerSeason from .forms import PlayerSearchForm from .models import Player, PlayerCareerEntry -from .services.search import annotate_player_metrics, apply_sorting, base_player_queryset, filter_players +from .services.search import ( + SEARCH_METRIC_SEMANTICS_TEXT, + annotate_player_metrics, + apply_sorting, + base_player_queryset, + filter_players, +) def calculate_age(birth_date): @@ -61,6 +67,7 @@ class PlayerSearchView(ListView): search_form = self.get_form() context["search_form"] = search_form context["search_has_errors"] = search_form.is_bound and bool(search_form.errors) + context["search_metric_semantics"] = SEARCH_METRIC_SEMANTICS_TEXT context["favorite_player_ids"] = set() if self.request.user.is_authenticated: player_ids = [player.id for player in context["players"]] diff --git a/apps/providers/adapters/balldontlie_provider.py b/apps/providers/adapters/balldontlie_provider.py index 8dc3c2b..b2d8b74 100644 --- a/apps/providers/adapters/balldontlie_provider.py +++ b/apps/providers/adapters/balldontlie_provider.py @@ -1,5 +1,4 @@ import logging -from itertools import islice from django.conf import settings @@ -30,6 +29,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter): """HTTP MVP adapter for balldontlie (NBA-centric data source).""" namespace = "balldontlie" + nba_prefix = "/nba/v1" def __init__(self, client: BalldontlieClient | None = None): self.client = client or BalldontlieClient() @@ -38,46 +38,23 @@ class BalldontlieProviderAdapter(BaseProviderAdapter): def configured_seasons(self) -> list[int]: return settings.PROVIDER_BALLDONTLIE_SEASONS - @staticmethod - def _chunked(values: list[int], size: int): - iterator = iter(values) - while True: - chunk = list(islice(iterator, size)) - if not chunk: - return - yield chunk - - def _fetch_game_ids(self) -> list[int]: - game_ids: set[int] = set() - for season in self.configured_seasons: - rows = self.client.list_paginated( - "games", - params={"seasons[]": season}, - per_page=settings.PROVIDER_BALLDONTLIE_GAMES_PER_PAGE, - page_limit=settings.PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT, - ) - for row in rows: - game_id = row.get("id") - if isinstance(game_id, int): - game_ids.add(game_id) - return sorted(game_ids) + def _api_path(self, path: str) -> str: + # Support both base URL variants: + # - https://api.balldontlie.io + # - https://api.balldontlie.io/nba/v1 + base = getattr(self.client, "base_url", "").rstrip("/") + if base.endswith("/nba/v1"): + return path.lstrip("/") + return f"{self.nba_prefix}/{path.lstrip('/')}" def _fetch_stats_rows(self) -> list[dict]: - game_ids = self._fetch_game_ids() - if not game_ids: - logger.info( - "provider_stats_skipped_no_games", - extra={"provider": self.namespace, "seasons": self.configured_seasons}, - ) - return [] - all_rows: list[dict] = [] try: - # Use game_ids[] query as documented in balldontlie getting-started flow. - for game_id_chunk in self._chunked(game_ids, 25): + # OpenAPI supports seasons[] directly for /nba/v1/stats. + for season in self.configured_seasons: rows = self.client.list_paginated( - "stats", - params={"game_ids[]": game_id_chunk}, + self._api_path("stats"), + params={"seasons[]": season}, per_page=settings.PROVIDER_BALLDONTLIE_STATS_PER_PAGE, page_limit=settings.PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT, ) @@ -101,7 +78,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter): def search_players(self, *, query: str = "", limit: int = 50, offset: int = 0) -> list[PlayerPayload]: params = {"search": query} if query else None rows = self.client.list_paginated( - "players", + self._api_path("players"), params=params, per_page=min(limit, settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE), page_limit=1, @@ -113,7 +90,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter): if not external_player_id.startswith("player-"): return None player_id = external_player_id.replace("player-", "", 1) - payload = self.client.get_json(f"players/{player_id}") + payload = self.client.get_json(self._api_path(f"players/{player_id}")) data = payload.get("data") if not isinstance(data, dict): return None @@ -122,7 +99,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter): def fetch_players(self) -> list[PlayerPayload]: rows = self.client.list_paginated( - "players", + self._api_path("players"), per_page=settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE, page_limit=settings.PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT, ) @@ -132,7 +109,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter): return map_competitions() def fetch_teams(self) -> list[TeamPayload]: - payload = self.client.get_json("teams") + payload = self.client.get_json(self._api_path("teams")) rows = payload.get("data") or [] return map_teams(rows if isinstance(rows, list) else []) diff --git a/apps/providers/clients/balldontlie.py b/apps/providers/clients/balldontlie.py index bfc02c6..edba84f 100644 --- a/apps/providers/clients/balldontlie.py +++ b/apps/providers/clients/balldontlie.py @@ -123,9 +123,6 @@ class BalldontlieClient: request_query["per_page"] = per_page if cursor is not None: request_query["cursor"] = cursor - else: - # Keep backwards compatibility for endpoints still supporting page-based pagination. - request_query["page"] = page payload = self.get_json(path, params=request_query) data = payload.get("data") or [] @@ -139,11 +136,6 @@ class BalldontlieClient: page += 1 continue - next_page = meta.get("next_page") - if next_page: - page = int(next_page) - continue - break return rows diff --git a/config/settings/base.py b/config/settings/base.py index a0f9e90..e6c9c7a 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -171,14 +171,12 @@ PROVIDER_MVP_DATA_FILE = os.getenv( PROVIDER_REQUEST_RETRIES = int(os.getenv("PROVIDER_REQUEST_RETRIES", "3")) PROVIDER_REQUEST_RETRY_SLEEP = float(os.getenv("PROVIDER_REQUEST_RETRY_SLEEP", "1")) PROVIDER_HTTP_TIMEOUT_SECONDS = float(os.getenv("PROVIDER_HTTP_TIMEOUT_SECONDS", "10")) -PROVIDER_BALLDONTLIE_BASE_URL = os.getenv("PROVIDER_BALLDONTLIE_BASE_URL", "https://api.balldontlie.io/nba/v1") +PROVIDER_BALLDONTLIE_BASE_URL = os.getenv("PROVIDER_BALLDONTLIE_BASE_URL", "https://api.balldontlie.io") PROVIDER_BALLDONTLIE_API_KEY = os.getenv("PROVIDER_BALLDONTLIE_API_KEY", "") PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT = int(os.getenv("PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT", "5")) PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE", "100")) PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT = int(os.getenv("PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT", "10")) PROVIDER_BALLDONTLIE_STATS_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_STATS_PER_PAGE", "100")) -PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT = int(os.getenv("PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT", "5")) -PROVIDER_BALLDONTLIE_GAMES_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_GAMES_PER_PAGE", "100")) PROVIDER_BALLDONTLIE_STATS_STRICT = env_bool("PROVIDER_BALLDONTLIE_STATS_STRICT", False) PROVIDER_BALLDONTLIE_SEASONS = [ int(value.strip()) diff --git a/docs/provider-normalization.md b/docs/provider-normalization.md index c727f14..64f8bf9 100644 --- a/docs/provider-normalization.md +++ b/docs/provider-normalization.md @@ -24,9 +24,11 @@ Raw provider response structures must remain inside `apps/providers` (client/ada - Source scope is NBA-centric. - Competition is normalized as a single NBA competition (`competition-nba`). +- API contract source is `https://www.balldontlie.io/openapi.yml` (server `https://api.balldontlie.io`, NBA endpoints under `/nba/v1/*`). - Team country is not reliably available in source payloads and is normalized to `null`. - Player nationality/birth/physical details are not available in player list payloads and are normalized to `null` (except fields explicitly present). - Configured seasons are normalized from `PROVIDER_BALLDONTLIE_SEASONS`; the highest configured season is marked `is_current=true`. +- Stats ingestion uses `/nba/v1/stats` with `seasons[]` and cursor pagination. - Advanced metrics (`usage_rate`, `true_shooting_pct`, `player_efficiency_rating`) are currently unavailable from this source path and normalized to `null`. ## Domain rules vs provider assumptions diff --git a/static/css/main.css b/static/css/main.css index 4db1a17..213657f 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1 +1,1186 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}h1{font-size:1.5rem;line-height:2rem}h1,h2{font-weight:600;letter-spacing:-.025em;--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}h2{font-size:1.25rem}h2,h3{line-height:1.75rem}h3{font-size:1.125rem;font-weight:600;color:rgb(15 23 42/var(--tw-text-opacity,1))}a,h3{--tw-text-opacity:1}a{color:rgb(24 79 179/var(--tw-text-opacity,1))}a:hover{--tw-text-opacity:1;color:rgb(29 99 221/var(--tw-text-opacity,1))}label{margin-bottom:.25rem;display:block;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}input,select,textarea{width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-opacity:1;--tw-ring-color:rgb(29 99 221/var(--tw-ring-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}input:focus,select:focus,textarea:focus{--tw-border-opacity:1;border-color:rgb(29 99 221/var(--tw-border-opacity,1));--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}input[type=checkbox]{height:1rem;width:1rem;border-radius:.25rem;--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1));padding:0;--tw-text-opacity:1;color:rgb(24 79 179/var(--tw-text-opacity,1))}summary{cursor:pointer;font-weight:500;--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.page-container{margin-left:auto;margin-right:auto;width:100%;max-width:72rem;padding-left:1rem;padding-right:1rem}@media (min-width:640px){.page-container{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:1024px){.page-container{padding-left:2rem;padding-right:2rem}}.panel{border-radius:.75rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1));padding:1.25rem;--tw-shadow:0 8px 24px -14px rgba(16,35,64,.35);--tw-shadow-colored:0 8px 24px -14px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn{display:inline-flex;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(24 79 179/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(24 79 179/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.btn:hover{--tw-bg-opacity:1;background-color:rgb(29 99 221/var(--tw-bg-opacity,1))}.btn-secondary{display:inline-flex;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.btn-secondary:hover{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.table-wrap{overflow-x:auto;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.data-table{min-width:100%}.data-table>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(226 232 240/var(--tw-divide-opacity,1))}.data-table{font-size:.875rem;line-height:1.25rem}.data-table thead{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.data-table th{padding:.5rem .75rem;text-align:left;font-size:.75rem;line-height:1rem;font-weight:600;text-transform:uppercase;letter-spacing:.025em;--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.data-table td{white-space:nowrap;padding:.5rem .75rem;--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.empty-state{border-radius:.5rem;border-width:1px;border-style:dashed;--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1));padding:1.5rem;text-align:center;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.htmx-indicator{display:none}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{display:block}.static{position:static}.mx-auto{margin-left:auto;margin-right:auto}.mb-4{margin-bottom:1rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-full{height:100%}.min-h-full{min-height:100%}.min-w-56{min-width:14rem}.max-w-lg{max-width:32rem}.flex-1{flex:1 1 0%}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-slate-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(241 245 249/var(--tw-divide-opacity,1))}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-emerald-200{--tw-border-opacity:1;border-color:rgb(167 243 208/var(--tw-border-opacity,1))}.border-rose-200{--tw-border-opacity:1;border-color:rgb(254 205 211/var(--tw-border-opacity,1))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.bg-emerald-50{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.bg-rose-50{--tw-bg-opacity:1;background-color:rgb(255 241 242/var(--tw-bg-opacity,1))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.p-3{padding:.75rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pb-2{padding-bottom:.5rem}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.tracking-tight{letter-spacing:-.025em}.text-emerald-800{--tw-text-opacity:1;color:rgb(6 95 70/var(--tw-text-opacity,1))}.text-rose-800{--tw-text-opacity:1;color:rgb(159 18 57/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.text-slate-900{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}.no-underline{text-decoration-line:none}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}@media (min-width:768px){.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +/* +! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden]:where(:not([hidden="until-found"])) { + display: none; +} + +body { + --tw-bg-opacity: 1; + background-color: rgb(241 245 249 / var(--tw-bg-opacity, 1)); + --tw-text-opacity: 1; + color: rgb(15 23 42 / var(--tw-text-opacity, 1)); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +h1 { + font-size: 1.5rem; + line-height: 2rem; + font-weight: 600; + letter-spacing: -0.025em; + --tw-text-opacity: 1; + color: rgb(15 23 42 / var(--tw-text-opacity, 1)); +} + +h2 { + font-size: 1.25rem; + line-height: 1.75rem; + font-weight: 600; + letter-spacing: -0.025em; + --tw-text-opacity: 1; + color: rgb(15 23 42 / var(--tw-text-opacity, 1)); +} + +h3 { + font-size: 1.125rem; + line-height: 1.75rem; + font-weight: 600; + --tw-text-opacity: 1; + color: rgb(15 23 42 / var(--tw-text-opacity, 1)); +} + +a { + --tw-text-opacity: 1; + color: rgb(24 79 179 / var(--tw-text-opacity, 1)); +} + +a:hover { + --tw-text-opacity: 1; + color: rgb(29 99 221 / var(--tw-text-opacity, 1)); +} + +label { + margin-bottom: 0.25rem; + display: block; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + --tw-text-opacity: 1; + color: rgb(51 65 85 / var(--tw-text-opacity, 1)); +} + +input, + select, + textarea { + width: 100%; + border-radius: 0.375rem; + border-width: 1px; + --tw-border-opacity: 1; + border-color: rgb(203 213 225 / var(--tw-border-opacity, 1)); + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + --tw-text-opacity: 1; + color: rgb(15 23 42 / var(--tw-text-opacity, 1)); + --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-opacity: 1; + --tw-ring-color: rgb(29 99 221 / var(--tw-ring-opacity, 1)); + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +input:focus, + select:focus, + textarea:focus { + --tw-border-opacity: 1; + border-color: rgb(29 99 221 / var(--tw-border-opacity, 1)); + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +input[type='checkbox'] { + height: 1rem; + width: 1rem; + border-radius: 0.25rem; + --tw-border-opacity: 1; + border-color: rgb(203 213 225 / var(--tw-border-opacity, 1)); + padding: 0px; + --tw-text-opacity: 1; + color: rgb(24 79 179 / var(--tw-text-opacity, 1)); +} + +summary { + cursor: pointer; + font-weight: 500; + --tw-text-opacity: 1; + color: rgb(30 41 59 / var(--tw-text-opacity, 1)); +} + +.page-container { + margin-left: auto; + margin-right: auto; + width: 100%; + max-width: 72rem; + padding-left: 1rem; + padding-right: 1rem; +} + +@media (min-width: 640px) { + .page-container { + padding-left: 1.5rem; + padding-right: 1.5rem; + } +} + +@media (min-width: 1024px) { + .page-container { + padding-left: 2rem; + padding-right: 2rem; + } +} + +.panel { + border-radius: 0.75rem; + border-width: 1px; + --tw-border-opacity: 1; + border-color: rgb(226 232 240 / var(--tw-border-opacity, 1)); + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); + padding: 1.25rem; + --tw-shadow: 0 8px 24px -14px rgba(16, 35, 64, 0.35); + --tw-shadow-colored: 0 8px 24px -14px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.375rem; + border-width: 1px; + --tw-border-opacity: 1; + border-color: rgb(24 79 179 / var(--tw-border-opacity, 1)); + --tw-bg-opacity: 1; + background-color: rgb(24 79 179 / var(--tw-bg-opacity, 1)); + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.btn:hover { + --tw-bg-opacity: 1; + background-color: rgb(29 99 221 / var(--tw-bg-opacity, 1)); +} + +.btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.375rem; + border-width: 1px; + --tw-border-opacity: 1; + border-color: rgb(203 213 225 / var(--tw-border-opacity, 1)); + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + --tw-text-opacity: 1; + color: rgb(51 65 85 / var(--tw-text-opacity, 1)); + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.btn-secondary:hover { + --tw-bg-opacity: 1; + background-color: rgb(248 250 252 / var(--tw-bg-opacity, 1)); +} + +.table-wrap { + overflow-x: auto; + border-radius: 0.5rem; + border-width: 1px; + --tw-border-opacity: 1; + border-color: rgb(226 232 240 / var(--tw-border-opacity, 1)); +} + +.data-table { + min-width: 100%; +} + +.data-table > :not([hidden]) ~ :not([hidden]) { + --tw-divide-y-reverse: 0; + border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + border-bottom-width: calc(1px * var(--tw-divide-y-reverse)); + --tw-divide-opacity: 1; + border-color: rgb(226 232 240 / var(--tw-divide-opacity, 1)); +} + +.data-table { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.data-table thead { + --tw-bg-opacity: 1; + background-color: rgb(248 250 252 / var(--tw-bg-opacity, 1)); +} + +.data-table th { + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + text-align: left; + font-size: 0.75rem; + line-height: 1rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; + --tw-text-opacity: 1; + color: rgb(71 85 105 / var(--tw-text-opacity, 1)); +} + +.data-table td { + white-space: nowrap; + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + --tw-text-opacity: 1; + color: rgb(51 65 85 / var(--tw-text-opacity, 1)); +} + +.empty-state { + border-radius: 0.5rem; + border-width: 1px; + border-style: dashed; + --tw-border-opacity: 1; + border-color: rgb(203 213 225 / var(--tw-border-opacity, 1)); + --tw-bg-opacity: 1; + background-color: rgb(248 250 252 / var(--tw-bg-opacity, 1)); + padding: 1.5rem; + text-align: center; + font-size: 0.875rem; + line-height: 1.25rem; + --tw-text-opacity: 1; + color: rgb(71 85 105 / var(--tw-text-opacity, 1)); +} + +.htmx-indicator { + display: none; +} + +.htmx-request .htmx-indicator, + .htmx-request.htmx-indicator { + display: block; +} + +.static { + position: static; +} + +.mx-auto { + margin-left: auto; + margin-right: auto; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.mt-1 { + margin-top: 0.25rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-3 { + margin-top: 0.75rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.block { + display: block; +} + +.inline { + display: inline; +} + +.flex { + display: flex; +} + +.inline-flex { + display: inline-flex; +} + +.table { + display: table; +} + +.grid { + display: grid; +} + +.hidden { + display: none; +} + +.h-full { + height: 100%; +} + +.min-h-full { + min-height: 100%; +} + +.min-w-56 { + min-width: 14rem; +} + +.max-w-lg { + max-width: 32rem; +} + +.flex-1 { + flex: 1 1 0%; +} + +.list-inside { + list-style-position: inside; +} + +.list-disc { + list-style-type: disc; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.items-start { + align-items: flex-start; +} + +.items-end { + align-items: flex-end; +} + +.items-center { + align-items: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-2 { + gap: 0.5rem; +} + +.gap-3 { + gap: 0.75rem; +} + +.gap-4 { + gap: 1rem; +} + +.space-y-1 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.25rem * var(--tw-space-y-reverse)); +} + +.space-y-2 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); +} + +.space-y-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1rem * var(--tw-space-y-reverse)); +} + +.divide-y > :not([hidden]) ~ :not([hidden]) { + --tw-divide-y-reverse: 0; + border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + border-bottom-width: calc(1px * var(--tw-divide-y-reverse)); +} + +.divide-slate-100 > :not([hidden]) ~ :not([hidden]) { + --tw-divide-opacity: 1; + border-color: rgb(241 245 249 / var(--tw-divide-opacity, 1)); +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.rounded-md { + border-radius: 0.375rem; +} + +.border { + border-width: 1px; +} + +.border-b { + border-bottom-width: 1px; +} + +.border-emerald-200 { + --tw-border-opacity: 1; + border-color: rgb(167 243 208 / var(--tw-border-opacity, 1)); +} + +.border-rose-200 { + --tw-border-opacity: 1; + border-color: rgb(254 205 211 / var(--tw-border-opacity, 1)); +} + +.border-slate-200 { + --tw-border-opacity: 1; + border-color: rgb(226 232 240 / var(--tw-border-opacity, 1)); +} + +.bg-emerald-50 { + --tw-bg-opacity: 1; + background-color: rgb(236 253 245 / var(--tw-bg-opacity, 1)); +} + +.bg-rose-50 { + --tw-bg-opacity: 1; + background-color: rgb(255 241 242 / var(--tw-bg-opacity, 1)); +} + +.bg-slate-100 { + --tw-bg-opacity: 1; + background-color: rgb(241 245 249 / var(--tw-bg-opacity, 1)); +} + +.bg-slate-50 { + --tw-bg-opacity: 1; + background-color: rgb(248 250 252 / var(--tw-bg-opacity, 1)); +} + +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); +} + +.p-3 { + padding: 0.75rem; +} + +.p-4 { + padding: 1rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-3 { + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} + +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + +.pb-2 { + padding-bottom: 0.5rem; +} + +.text-base { + font-size: 1rem; + line-height: 1.5rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.font-bold { + font-weight: 700; +} + +.font-medium { + font-weight: 500; +} + +.font-semibold { + font-weight: 600; +} + +.tracking-tight { + letter-spacing: -0.025em; +} + +.text-emerald-800 { + --tw-text-opacity: 1; + color: rgb(6 95 70 / var(--tw-text-opacity, 1)); +} + +.text-rose-800 { + --tw-text-opacity: 1; + color: rgb(159 18 57 / var(--tw-text-opacity, 1)); +} + +.text-slate-500 { + --tw-text-opacity: 1; + color: rgb(100 116 139 / var(--tw-text-opacity, 1)); +} + +.text-slate-600 { + --tw-text-opacity: 1; + color: rgb(71 85 105 / var(--tw-text-opacity, 1)); +} + +.text-slate-700 { + --tw-text-opacity: 1; + color: rgb(51 65 85 / var(--tw-text-opacity, 1)); +} + +.text-slate-800 { + --tw-text-opacity: 1; + color: rgb(30 41 59 / var(--tw-text-opacity, 1)); +} + +.text-slate-900 { + --tw-text-opacity: 1; + color: rgb(15 23 42 / var(--tw-text-opacity, 1)); +} + +.no-underline { + text-decoration-line: none; +} + +.filter { + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.hover\:bg-slate-100:hover { + --tw-bg-opacity: 1; + background-color: rgb(241 245 249 / var(--tw-bg-opacity, 1)); +} + +@media (min-width: 768px) { + .md\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .md\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} diff --git a/templates/players/partials/results.html b/templates/players/partials/results.html index 3c77810..ff25c65 100644 --- a/templates/players/partials/results.html +++ b/templates/players/partials/results.html @@ -6,6 +6,9 @@ {{ page_obj.paginator.count }} player{{ page_obj.paginator.count|pluralize }} found +

+ {{ search_metric_semantics }} +

{% if search_has_errors %}
@@ -35,11 +38,11 @@ Pos / Role Origin Height / Weight - Games - MPG - PPG - RPG - APG + Best Eligible Games + Best Eligible MPG + Best Eligible PPG + Best Eligible RPG + Best Eligible APG {% if request.user.is_authenticated %}Watchlist{% endif %} diff --git a/tests/test_api.py b/tests/test_api.py index d5a30b7..04a2be1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -128,6 +128,57 @@ def test_players_api_search_consistent_with_ui_filters(client): assert api_response.json()["results"][0]["id"] == matching.id +@pytest.mark.django_db +def test_players_api_metric_sort_uses_best_eligible_values(client): + nationality = Nationality.objects.create(name="Romania", iso2_code="RO", iso3_code="ROU") + competition = Competition.objects.create( + name="LNBM", + slug="lnbm", + competition_type=Competition.CompetitionType.LEAGUE, + gender=Competition.Gender.MEN, + country=nationality, + ) + season_old = Season.objects.create(label="2022-2023", start_date=date(2022, 9, 1), end_date=date(2023, 6, 30)) + season_new = Season.objects.create(label="2023-2024", start_date=date(2023, 9, 1), end_date=date(2024, 6, 30)) + team = Team.objects.create(name="Bucharest", slug="bucharest", country=nationality) + + p1 = Player.objects.create(first_name="Ion", last_name="Low", full_name="Ion Low", nationality=nationality) + p1s = PlayerSeason.objects.create( + player=p1, + season=season_old, + team=team, + competition=competition, + games_played=20, + minutes_played=400, + ) + PlayerSeasonStats.objects.create(player_season=p1s, points=13, rebounds=3, assists=2, steals=1, blocks=0.1, turnovers=2) + + p2 = Player.objects.create(first_name="Dan", last_name="High", full_name="Dan High", nationality=nationality) + p2s_old = PlayerSeason.objects.create( + player=p2, + season=season_old, + team=team, + competition=competition, + games_played=20, + minutes_played=400, + ) + PlayerSeasonStats.objects.create(player_season=p2s_old, points=9, rebounds=3, assists=2, steals=1, blocks=0.1, turnovers=2) + p2s_new = PlayerSeason.objects.create( + player=p2, + season=season_new, + team=team, + competition=competition, + games_played=20, + minutes_played=500, + ) + PlayerSeasonStats.objects.create(player_season=p2s_new, points=22, rebounds=5, assists=4, steals=1.3, blocks=0.2, turnovers=2.3) + + response = client.get(reverse("api:players"), data={"sort": "ppg_desc"}) + assert response.status_code == 200 + names = [row["full_name"] for row in response.json()["results"]] + assert names.index("Dan High") < names.index("Ion Low") + + @pytest.mark.django_db def test_player_detail_api_includes_origin_fields(client): nationality = Nationality.objects.create(name="Greece", iso2_code="GR", iso3_code="GRC") diff --git a/tests/test_players_filters_advanced.py b/tests/test_players_filters_advanced.py index 8aa1892..b133790 100644 --- a/tests/test_players_filters_advanced.py +++ b/tests/test_players_filters_advanced.py @@ -332,3 +332,207 @@ def test_displayed_metrics_are_scoped_to_filtered_context(client): row = list(response.context["players"])[0] assert float(row.ppg_value) == pytest.approx(9.0) assert float(row.mpg_value) == pytest.approx(25.0) + + +@pytest.mark.django_db +def test_displayed_metrics_are_scoped_to_team_season_competition_context(client): + nationality = Nationality.objects.create(name="Czechia", iso2_code="CZ", iso3_code="CZE") + position = Position.objects.create(code="SF", name="Small Forward") + role = Role.objects.create(code="wing", name="Wing") + competition_a = Competition.objects.create( + name="NBL", + slug="nbl-cz", + competition_type=Competition.CompetitionType.LEAGUE, + gender=Competition.Gender.MEN, + country=nationality, + ) + competition_b = Competition.objects.create( + name="CZ Cup", + slug="cz-cup", + competition_type=Competition.CompetitionType.CUP, + gender=Competition.Gender.MEN, + country=nationality, + ) + season = Season.objects.create(label="2024-2025", start_date=date(2024, 9, 1), end_date=date(2025, 6, 30)) + team = Team.objects.create(name="Prague", slug="prague", country=nationality) + player = Player.objects.create( + first_name="Adam", + last_name="Scope", + full_name="Adam Scope", + birth_date=date(2002, 2, 2), + nationality=nationality, + nominal_position=position, + inferred_role=role, + ) + + ps_a = PlayerSeason.objects.create( + player=player, + season=season, + team=team, + competition=competition_a, + games_played=12, + minutes_played=300, + ) + PlayerSeasonStats.objects.create(player_season=ps_a, points=11, rebounds=4, assists=3, steals=1, blocks=0.2, turnovers=1.5) + + ps_b = PlayerSeason.objects.create( + player=player, + season=season, + team=team, + competition=competition_b, + games_played=6, + minutes_played=210, + ) + PlayerSeasonStats.objects.create(player_season=ps_b, points=24, rebounds=6, assists=5, steals=1.5, blocks=0.3, turnovers=2.2) + + response = client.get( + reverse("players:index"), + data={"team": team.id, "season": season.id, "competition": competition_a.id}, + ) + assert response.status_code == 200 + row = list(response.context["players"])[0] + assert float(row.ppg_value) == pytest.approx(11.0) + assert float(row.mpg_value) == pytest.approx(25.0) + + +@pytest.mark.django_db +def test_displayed_metrics_without_season_filter_use_best_eligible_values(client): + nationality = Nationality.objects.create(name="Slovenia", iso2_code="SI", iso3_code="SVN") + position = Position.objects.create(code="PG", name="Point Guard") + role = Role.objects.create(code="playmaker", name="Playmaker") + competition = Competition.objects.create( + name="SBL", + slug="sbl", + competition_type=Competition.CompetitionType.LEAGUE, + gender=Competition.Gender.MEN, + country=nationality, + ) + season_a = Season.objects.create(label="2023-2024", start_date=date(2023, 9, 1), end_date=date(2024, 6, 30)) + season_b = Season.objects.create(label="2024-2025", start_date=date(2024, 9, 1), end_date=date(2025, 6, 30)) + team = Team.objects.create(name="Ljubljana", slug="ljubljana", country=nationality) + + player = Player.objects.create( + first_name="Luka", + last_name="Semantics", + full_name="Luka Semantics", + birth_date=date(2001, 3, 3), + nationality=nationality, + nominal_position=position, + inferred_role=role, + ) + + # High games, lower per-game production. + ps_a = PlayerSeason.objects.create( + player=player, + season=season_a, + team=team, + competition=competition, + games_played=34, + minutes_played=680, # 20 MPG + ) + PlayerSeasonStats.objects.create( + player_season=ps_a, + points=10.0, + rebounds=3.0, + assists=4.0, + steals=1.0, + blocks=0.1, + turnovers=2.0, + ) + + # Lower games, higher per-game production. + ps_b = PlayerSeason.objects.create( + player=player, + season=season_b, + team=team, + competition=competition, + games_played=18, + minutes_played=630, # 35 MPG + ) + PlayerSeasonStats.objects.create( + player_season=ps_b, + points=25.0, + rebounds=6.0, + assists=8.0, + steals=1.5, + blocks=0.2, + turnovers=3.0, + ) + + response = client.get(reverse("players:index")) + assert response.status_code == 200 + row = next(item for item in response.context["players"] if item.id == player.id) + assert float(row.games_played_value) == pytest.approx(34.0) + assert float(row.mpg_value) == pytest.approx(35.0) + assert float(row.ppg_value) == pytest.approx(25.0) + + +@pytest.mark.django_db +def test_ppg_sort_uses_best_eligible_metrics_without_season_filter(client): + nationality = Nationality.objects.create(name="Latvia", iso2_code="LV", iso3_code="LVA") + position = Position.objects.create(code="SG", name="Shooting Guard") + role = Role.objects.create(code="scorer", name="Scorer") + competition = Competition.objects.create( + name="LBL", + slug="lbl", + competition_type=Competition.CompetitionType.LEAGUE, + gender=Competition.Gender.MEN, + country=nationality, + ) + season_old = Season.objects.create(label="2022-2023", start_date=date(2022, 9, 1), end_date=date(2023, 6, 30)) + season_new = Season.objects.create(label="2023-2024", start_date=date(2023, 9, 1), end_date=date(2024, 6, 30)) + team = Team.objects.create(name="Riga", slug="riga", country=nationality) + + player_a = Player.objects.create( + first_name="A", + last_name="Sorter", + full_name="A Sorter", + birth_date=date(2000, 1, 1), + nationality=nationality, + nominal_position=position, + inferred_role=role, + ) + ps_a = PlayerSeason.objects.create( + player=player_a, + season=season_old, + team=team, + competition=competition, + games_played=20, + minutes_played=400, + ) + PlayerSeasonStats.objects.create(player_season=ps_a, points=14, rebounds=4, assists=3, steals=1, blocks=0.1, turnovers=2) + + player_b = Player.objects.create( + first_name="B", + last_name="Sorter", + full_name="B Sorter", + birth_date=date(2000, 1, 1), + nationality=nationality, + nominal_position=position, + inferred_role=role, + ) + # Lower old season. + ps_b_old = PlayerSeason.objects.create( + player=player_b, + season=season_old, + team=team, + competition=competition, + games_played=20, + minutes_played=400, + ) + PlayerSeasonStats.objects.create(player_season=ps_b_old, points=12, rebounds=3, assists=2, steals=1, blocks=0.1, turnovers=2) + # Better newer season; should drive best-eligible ppg sort. + ps_b_new = PlayerSeason.objects.create( + player=player_b, + season=season_new, + team=team, + competition=competition, + games_played=20, + minutes_played=420, + ) + PlayerSeasonStats.objects.create(player_season=ps_b_new, points=21, rebounds=5, assists=4, steals=1.2, blocks=0.2, turnovers=2.5) + + response = client.get(reverse("players:index"), data={"sort": "ppg_desc"}) + assert response.status_code == 200 + ordered = [row.full_name for row in response.context["players"] if row.full_name in {"A Sorter", "B Sorter"}] + assert ordered.index("B Sorter") < ordered.index("A Sorter") diff --git a/tests/test_players_views.py b/tests/test_players_views.py index d055b86..7065000 100644 --- a/tests/test_players_views.py +++ b/tests/test_players_views.py @@ -193,3 +193,52 @@ def test_player_search_htmx_invalid_filters_return_validation_feedback(client): body = response.content.decode().lower() assert "search filters are invalid" in body assert "points per game min" in body + + +@pytest.mark.django_db +def test_player_search_results_render_best_eligible_metric_labels(client): + nationality = Nationality.objects.create(name="Ireland", iso2_code="IE", iso3_code="IRL") + position = Position.objects.create(code="PG", name="Point Guard") + role = Role.objects.create(code="playmaker", name="Playmaker") + season = Season.objects.create(label="2025-2026", start_date=date(2025, 9, 1), end_date=date(2026, 6, 30)) + competition = Competition.objects.create( + name="Super League", + slug="super-league", + competition_type=Competition.CompetitionType.LEAGUE, + gender=Competition.Gender.MEN, + country=nationality, + ) + team = Team.objects.create(name="Dublin", slug="dublin", country=nationality) + player = Player.objects.create( + first_name="Sean", + last_name="Label", + full_name="Sean Label", + birth_date=date(2001, 1, 1), + nationality=nationality, + nominal_position=position, + inferred_role=role, + ) + season_row = PlayerSeason.objects.create( + player=player, + season=season, + team=team, + competition=competition, + games_played=10, + minutes_played=250, + ) + PlayerSeasonStats.objects.create( + player_season=season_row, + points=12, + rebounds=4, + assists=5, + steals=1, + blocks=0.1, + turnovers=2, + ) + + response = client.get(reverse("players:index")) + assert response.status_code == 200 + body = response.content.decode() + assert "Best Eligible PPG" in body + assert "Best Eligible MPG" in body + assert "best eligible values per player" in body.lower() diff --git a/tests/test_provider_balldontlie.py b/tests/test_provider_balldontlie.py index bd40281..8b13c76 100644 --- a/tests/test_provider_balldontlie.py +++ b/tests/test_provider_balldontlie.py @@ -40,7 +40,7 @@ class _FakeSession: class _FakeBalldontlieClient: def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]: - if path == "teams": + if path == "/nba/v1/teams": return { "data": [ { @@ -60,7 +60,7 @@ class _FakeBalldontlieClient: per_page: int = 100, page_limit: int = 1, ) -> list[dict[str, Any]]: - if path == "players": + if path == "/nba/v1/players": return [ { "id": 237, @@ -70,10 +70,7 @@ class _FakeBalldontlieClient: "team": {"id": 14}, } ] - if path == "stats": - requested_ids = (params or {}).get("game_ids[]") or [] - if requested_ids and 9902 not in requested_ids: - return [] + if path == "/nba/v1/stats": return [ { "pts": 20, @@ -88,7 +85,7 @@ class _FakeBalldontlieClient: "min": "35:12", "player": {"id": 237}, "team": {"id": 14}, - "game": {"id": 9901, "season": 2024}, + "game": {"season": 2024}, }, { "pts": 30, @@ -103,14 +100,9 @@ class _FakeBalldontlieClient: "min": "33:00", "player": {"id": 237}, "team": {"id": 14}, - "game": {"id": 9902, "season": 2024}, + "game": {"season": 2024}, }, ] - if path == "games": - return [ - {"id": 9901, "season": 2024}, - {"id": 9902, "season": 2024}, - ] return [] @@ -179,7 +171,7 @@ def test_balldontlie_map_seasons_marks_latest_as_current(): def test_balldontlie_adapter_degrades_when_stats_unauthorized(settings): class _UnauthorizedStatsClient(_FakeBalldontlieClient): def list_paginated(self, path: str, *, params=None, per_page=100, page_limit=1): - if path == "stats": + if path == "/nba/v1/stats": raise ProviderUnauthorizedError( provider="balldontlie", path="stats", @@ -266,6 +258,6 @@ def test_balldontlie_client_cursor_pagination(settings): rows = client.list_paginated("players", per_page=1, page_limit=5) assert rows == [{"id": 1}, {"id": 2}] - assert session.calls[0]["params"]["page"] == 1 + assert "page" not in session.calls[0]["params"] assert "cursor" not in session.calls[0]["params"] assert session.calls[1]["params"]["cursor"] == 101