diff --git a/CLAUDE.md b/CLAUDE.md index f0f9db7..776841d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Stack - **Backend**: FastAPI + PyMongo (Python 3.12), runs on port 8000 -- **Frontend**: React 18 + TypeScript + Vite + Tailwind CSS, runs on port 5173 +- **Frontend**: React 18 + TypeScript + Vite + Tailwind CSS (dark theme tokens, Inter + JetBrains Mono via `@fontsource`, `lucide-react` icons), runs on port 5173 - **Database**: MongoDB 7 (`football_analytics` db) - **Scraping**: botasaurus + Chrome (inside Docker) for Sofascore; Tor is present but Sofascore's Cloudflare 403s it, so Chrome scrapes run without the Tor proxy - **Chatbot agent**: LangChain + LangGraph (`create_agent`), configurable LLM provider (`app/agent/providers.py`); Gemini free tier by default @@ -76,8 +76,9 @@ app/ players.py # GET /v1/players, GET /v1/players/{id} analysis.py # GET /v1/analysis/scatter chat.py # POST /v1/chat, GET/DELETE /v1/chat/sessions/{session_id} + meta.py # GET /v1/meta — player count, last fetch time, stored seasons, chat model + tool-call cap modals/ - chat_modals.py # ChatRequest, ChatResponse, ChatTurn, ChatHistory + chat_modals.py # ChatRequest, ChatResponse (+ tool_calls, uncited), ChatTurn, ChatHistory modes/ # Strategy pattern base.py # AnalysisMode ABC: fetch_data(), process() factory.py # ModeFactory.create("fantasy") @@ -118,9 +119,9 @@ app/ **Fantasy mode** (live fetch): `POST /v1/fetch/` → `FantasyMode.fetch_data()` → fetches from Sofascore per competition → `player_assembler.build_player()` → scores via `ScoringEngine` → upserts to MongoDB. -**Read path**: `GET /v1/players` → `MongoRepository.get_players()` → paginates + filters by position/team/nationality/sleeper_flag. +**Read path**: `GET /v1/players` → `MongoRepository.get_players()` → paginates + filters by name/position/team/nationality/sleeper_flag. `name` and `team` match `norm_name`/`norm_team` by substring, so they are case- and accent-insensitive; passing both combines them with AND. -**Chat path**: `POST /v1/chat` → `ChatAgent.answer()` → LangGraph `create_agent` loop calls metric-family tools (each wraps `MongoRepository.get_players()`) → `answer_check.uncited_numbers()` flags any figure in the reply not present in a tool row (sets `degraded=True`, does not block the reply) → session state checkpointed to MongoDB by `session_id` (thread id). If the agent could not be built at startup (e.g. no `GEMINI_API_KEY`), `get_agent()` returns `None` and `/v1/chat` responds with a degraded generic answer instead of failing. +**Chat path**: `POST /v1/chat` → `ChatAgent.answer()` → LangGraph `create_agent` loop calls metric-family tools (each wraps `MongoRepository.get_players()`) → `answer_check.uncited_numbers()` flags any figure in the reply not present in a tool row (sets `degraded=True`, does not block the reply). The response also carries `tool_calls` (tool names and row counts only, never arguments or rows) and the `uncited` figures, which the UI shows as a trace and a warning → session state checkpointed to MongoDB by `session_id` (thread id). Because the team filter matches by substring, a tool call whose `team` hits more than one club returns a disambiguation row instead of a merged ranking, and the model asks again with a full team name. If the agent could not be built at startup (e.g. no `GEMINI_API_KEY`), `get_agent()` returns `None` and `/v1/chat` responds with a degraded generic answer instead of failing. ### MongoDB collections @@ -141,15 +142,16 @@ app/ ### Frontend pages -- `Rankings` — paginated player table with position/team filter -- `PlayerDetail` — single player view by Sofascore ID (modal; also opens for players without a Sofascore ID) -- `Compare` — side-by-side exactly 2 players -- `Sleepers` — filtered to sleeper_flag players -- `ScatterPage` — xG+xA vs G+A scatter plot via Recharts +The UI follows the sketches in `docs/superpowers/specs/2026-09-24-ui-redesign-design.md`. `AppShell` holds the tabs, the dataset status from `GET /v1/meta` and the season selector; the season is lifted state shared through `src/context/AppContext.tsx` (which also has `go(tab)` for cross-page jumps). Shared primitives live in `src/components/ui/`; empty-DB, backend-unreachable (auto-retry every 10 s) and no-results states live in `src/components/states/SystemStates.tsx`. -Data loading is developer-driven via `tools/fetch_cli` (see its README) — the frontend has no fetch-triggering UI; when the DB is empty it just points to the CLI. +- `PlayerDetails` — paginated player table plus live search; the name and team fields filter as you type (250 ms debounce, stale responses dropped). Metric filters are added from a popover and shown as removable chips. Clicking a row opens the player modal (score panel with confidence, per-competition scores, goal-type and shot-outcome bars, "Compare with..." jump), which also opens for players without a Sofascore ID +- `Compare` — exactly 2 players: player cards plus mirrored bars and a Δ column +- `Sleepers` — "xGI Outliers" tab: players whose G+A diverges from xG+xA ("Due to score" / "Overperforming"), sorted by `sort_by=xratio` +- `ScatterPage` — Recharts scatter with X/Y metric selects, position and minutes filters, outlier highlighting, and a selected-point panel -`ChatWidget` — floating chat bubble rendered on every tab (`src/components/ChatWidget.tsx`); opens `ChatPanel`. `?chat=1` renders `ChatFullScreen` instead of the tabbed `Dashboard` (`src/App.tsx`), reusing the same `ChatPanel`. Deliberately no navbar tab. Session id is generated client-side (`src/api/chat.ts`) and persisted for `GET/DELETE /v1/chat/sessions/{session_id}`. +Data loading is developer-driven via `tools/fetch_cli` (see its README) — the frontend has no fetch-triggering UI; when the DB is empty it shows the CLI commands. + +`ChatWidget` — floating chat bubble rendered on every tab except "Ask AI" (`src/components/ChatWidget.tsx`); opens `ChatPanel`. The "Ask AI" navbar tab renders `ChatPanel` full height. `?chat=1` renders `ChatFullScreen` instead of the tabbed `Dashboard` (`src/App.tsx`), reusing the same `ChatPanel`. Session id is generated client-side (`src/api/chat.ts`) and persisted for `GET/DELETE /v1/chat/sessions/{session_id}`. ### tools/fetch_cli diff --git a/Mathematical_Specification.md b/Mathematical_Specification.md index c62ef05..78f90b7 100644 --- a/Mathematical_Specification.md +++ b/Mathematical_Specification.md @@ -20,7 +20,7 @@ $$M_{early} = \min\!\left(\frac{Minutes}{Appearances},\ 59\right) \times Appeara $$M_{late} = \max\!\left(0,\ \min\!\left(\frac{Minutes}{Appearances},\ 90\right) - 59\right) \times Appearances$$ -- $C_{apps}$: appearance-based confidence multiplier. Dampens inflated per-90 rates for low-game-count players; reaches full weight at 20+ appearances. Team-specific match tracking is a planned improvement. +- $C_{apps}$: appearance-based confidence multiplier. Dampens inflated per-90 rates for low-game-count players; reaches full weight at 20+ appearances. Team-specific match tracking is a planned improvement. Exposed via the API as `aggregated_scores.confidence`, computed at request time by `confidence_tier()`/`effective_appearances()` in `scoring_engine.py` — it is not stored in MongoDB. - $M_{early}$ / $M_{late}$: estimated playing minutes split at the 60th minute, using average minutes per appearance as a proxy (per-match breakdowns unavailable). Extra time not counted. - $B_{time}$ rewards playing time: minutes 60–90 earn 50% more per minute than early minutes. - If $Minutes = 0$, $S_{final} = 0$. The three pillars are still computed. diff --git a/README.md b/README.md index 7d68e25..481ea9c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This project is a **free, open-source, educational tool** built for football ent | Layer | Technology | |---|---| -| Frontend | React 18 · TypeScript · Vite · Tailwind CSS · Recharts · react-markdown | +| Frontend | React 18 · TypeScript · Vite · Tailwind CSS · Recharts · react-markdown · lucide-react icons · Inter/JetBrains Mono fonts | | Backend | FastAPI · Python 3.12 · PyMongo · Pydantic Settings | | Chatbot Agent | LangChain · LangGraph (`create_agent`), configurable LLM provider (Gemini free tier by default) | | Database | MongoDB 7 | @@ -65,22 +65,24 @@ flowchart LR **Fetch path:** developer runs `tools/fetch_cli` → `POST /v1/fetch/` → `FantasyMode` → `FetchRunner` pulls stats per competition (concurrent, no fetch rate limit) → `PlayerAssembler` scores via `ScoringEngine` and classifies sleepers → `MongoRepository` upserts to `player_bios` / `player_stats`. -**Read path:** React SPA → API routers → `MongoRepository.get_players()` → paginated and filterable by position, team, nationality, or sleeper flag. +**Read path:** React SPA → API routers → `MongoRepository.get_players()` → paginated and filterable by name, team, position, nationality, or sleeper flag; name and team are case- and accent-insensitive substring matches, combined with AND when both are set. Sorting also accepts `sort_by=xratio` (the sleeper ratio), which is sort-only — it is not a filter metric and not an agent tool metric. -**Chat path:** floating chat widget (or `?chat=1` full-screen view) → `POST /v1/chat` → `ChatAgent` (LangGraph `create_agent` loop over per-metric-family DB query tools) → answer built from the rows those tools returned. Session history is a MongoDB checkpoint per thread, expiring 7 days after the last message. +**Chat path:** floating chat widget (or `?chat=1` full-screen view) → `POST /v1/chat` → `ChatAgent` (LangGraph `create_agent` loop over per-metric-family DB query tools) → answer built from the rows those tools returned. The response also carries `tool_calls` (tool name + row count only, never arguments or row contents) and `uncited` (figures in the answer with no backing row), which the UI renders as a trace and a warning. Session history is a MongoDB checkpoint per thread, expiring 7 days after the last message. + +**Meta path:** `GET /v1/meta?season=` → player count, last fetch time, stored seasons, and the active chat model/tool-call cap. Powers the app header's dataset status, season selector, and the Ask AI tab's model line. --- ## Core Features - **Fantasy Scoring** — composite score `S_final = raw_per90 x starter_bonus x confidence + playing_time_bonus`, where `raw_per90` is `(Offensive + Defensive + Tactical) / (minutes / 90)` with position-specific goal/assist weights (GK goals worth 10 pts, FW goals worth 4 pts). `starter_bonus` rewards regular starters and `confidence` discounts small appearance counts — see `Mathematical_Specification.md` -- **Rankings** — paginated player table sorted by `S_final`; filterable by position, team, nationality, and sleeper flag -- **Defensive Metrics** — tackles, interceptions, clearances, blocks, aerial duels, ball recoveries, and errors leading to a shot/goal are tracked per player and sortable/filterable in Rankings; not yet part of `S_final` scoring -- **Sleeper Detection** — `HIGH_VALUE` flags players where xG+xA significantly exceeds G+A; `OVERPERFORMING` flags the inverse; gated on `minutes > 450` -- **Player Detail** — per-competition stat breakdown and aggregated scores for any player, including those without a linked external ID -- **Head-to-Head Compare** — side-by-side comparison of exactly two players across all stat dimensions -- **Scatter Plot** — interactive xG+xA vs G+A chart (Recharts) across the full dataset -- **Chat Agent** — floating widget on every tab (also a full-screen view at `?chat=1`) answers natural-language questions about players and metrics from live DB tool calls; figures with no supporting row are flagged, and questions the database cannot answer are answered from the model's own knowledge and labelled as such; no navbar tab by design +- **Player Details** — paginated player table sorted by Fantasy Score (`S_final`) by default; live-filterable (250ms debounce) by name, team, position, nationality, and sleeper flag, plus a removable-chip "Add metric filter" popover for any allowlisted metric; click a row for the per-competition stat breakdown and aggregated scores, including players without a linked external ID +- **Defensive Metrics** — tackles, interceptions, clearances, blocks, aerial duels, ball recoveries, and errors leading to a shot/goal are tracked per player and sortable/filterable in Player Details; not yet part of `S_final` scoring +- **Sleeper Detection** — `HIGH_VALUE` flags players where xG+xA significantly exceeds G+A; `OVERPERFORMING` flags the inverse; gated on `minutes > 450`. The xGI Outliers page sorts each tab by the xG+xA/G+A ratio (descending for "Due to score", ascending for "Overperforming") +- **Head-to-Head Compare** — side-by-side comparison of exactly two players across all stat dimensions, with mirrored bars and a per-row delta +- **Scatter Plot** — interactive xG+xA vs G+A chart (Recharts) with selectable X/Y metrics, a position filter, a minimum-minutes filter, an outlier-highlight toggle, and a detail panel for the selected point +- **Dataset Status** — the app header shows player count, last fetch time, and a season selector, backed by `GET /v1/meta`; the Ask AI tab shows the active chat model and its tool-call cap instead +- **Chat Agent** — "Ask AI" navbar tab plus a floating widget on the other tabs (also a full-screen view at `?chat=1`) answers natural-language questions about players and metrics from live DB tool calls; each answer shows which tools ran and how many rows they returned, figures with no supporting row are flagged with a warning, and questions the database cannot answer are answered from the model's own knowledge and labelled as such - **Developer Data Loading** — `tools/fetch_cli`, a standalone CLI for browsing available competitions/seasons and loading data into MongoDB, with live per-task fetch progress - **DB Snapshots** — JSON dump/restore scripts (`backend/scripts/DB/`) for safe local dev iteration @@ -131,7 +133,7 @@ Player data is split across two MongoDB collections: } ``` -`competition_type` is `"club"` for domestic leagues and cups, `"national"` for international tournaments (World Cup, European Championship, etc.). The stats-view filter on the Rankings page uses this field to let you see club-only or national-team-only aggregated scores. +`competition_type` is `"club"` for domestic leagues and cups, `"national"` for international tournaments (World Cup, European Championship, etc.). The stats-view filter on the Player Details page uses this field to let you see club-only or national-team-only aggregated scores. ## Running the Project diff --git a/backend/app/agent/agent.py b/backend/app/agent/agent.py index 5145aa0..249748d 100644 --- a/backend/app/agent/agent.py +++ b/backend/app/agent/agent.py @@ -3,7 +3,7 @@ import asyncio import logging from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from functools import partial from langchain.agents import create_agent @@ -22,11 +22,19 @@ logger = logging.getLogger(__name__) +@dataclass +class ToolCallSummary: + name: str + rows: int + + @dataclass class ChatResult: answer: str used_tools: bool degraded: bool = False + tool_calls: list[ToolCallSummary] = field(default_factory=list) + uncited: list[str] = field(default_factory=list) class ChatAgent: @@ -69,6 +77,7 @@ async def answer(self, message: str, session_id: str) -> ChatResult: messages = _current_turn(state["messages"]) used_tools = any(isinstance(m, ToolMessage) for m in messages) answer = messages[-1].text if messages else "" + trace = _tool_trace(messages) if used_tools and answer: rows = _tool_rows(messages) @@ -81,13 +90,16 @@ async def answer(self, message: str, session_id: str) -> ChatResult: session_id, rows, ) - return ChatResult(answer=answer, used_tools=True, degraded=True) + return ChatResult( + answer=answer, used_tools=True, degraded=True, tool_calls=trace, uncited=uncited + ) # No tool behind the answer means it is the model's own knowledge, not this app's data. return ChatResult( answer=answer or GENERIC_ERROR, used_tools=used_tools, degraded=not (answer and used_tools), + tool_calls=trace, ) async def _prune_session(self, session_id: str) -> None: @@ -150,6 +162,22 @@ def _tool_rows(messages) -> list[dict] | None: return rows +def _tool_trace(messages) -> list[ToolCallSummary]: + """Name and row count of each tool call this turn; arguments are never kept.""" + names = {c["id"]: c["name"] for m in messages if isinstance(m, AIMessage) for c in m.tool_calls} + trace = [] + for m in messages: + if not isinstance(m, ToolMessage): + continue + try: + payload = _ToolPayload.validate_json(m.content) + rows = len(payload) if isinstance(payload, list) else 1 + except ValidationError: + rows = 0 + trace.append(ToolCallSummary(name=names.get(m.tool_call_id, m.name or "tool"), rows=rows)) + return trace + + def build_agent(repo, mongo_client) -> ChatAgent: checkpointer = build_checkpointer(mongo_client) return ChatAgent( diff --git a/backend/app/agent/tools/base.py b/backend/app/agent/tools/base.py index 9ffd141..4ac6cec 100644 --- a/backend/app/agent/tools/base.py +++ b/backend/app/agent/tools/base.py @@ -45,6 +45,12 @@ def run_metric_query(repo, family: set[str], q: MetricQuery) -> list[dict]: if q.metric not in family or q.metric not in METRIC_FIELDS: return [{"error": f"{q.metric!r} is not available in this tool."}] + if q.team: + # The team filter matches by substring, so "Manchester" would merge City and United. + teams = repo.matching_teams(settings.season, q.team) + if len(teams) > 1: + return [{"error": f"{q.team!r} matches {teams}. Ask again with one full team name."}] + filters: list[dict] = [] if q.min_value is not None: filters.append({"field": q.metric, "op": "gte", "value": float(q.min_value)}) diff --git a/backend/app/api/analysis.py b/backend/app/api/analysis.py index eff5dc2..806fb2f 100644 --- a/backend/app/api/analysis.py +++ b/backend/app/api/analysis.py @@ -13,18 +13,30 @@ def scatter_data( season: str | None = None, repo: MongoRepository = Depends(get_repo), ) -> ScatterDataOut: - """Return xG+xA vs G+A scatter data points for all players in a season.""" + """Return one scatter point per player in a season; the client picks the axes.""" raw = repo.get_scatter_data(season or settings.season) points = [] for doc in raw: agg = doc.get("aggregated_stats", {}) + scores = doc.get("aggregated_scores", {}) + xg, xa = float(agg.get("xg", 0)), float(agg.get("xa", 0)) + goals, assists = int(agg.get("goals", 0)), int(agg.get("assists", 0)) points.append( ScatterPoint( sofascore_player_id=doc.get("sofascore_player_id"), name=doc.get("name", ""), position=doc.get("position", ""), - xg_xa=float(agg.get("xg", 0)) + float(agg.get("xa", 0)), - g_a=float(agg.get("goals", 0)) + float(agg.get("assists", 0)), + team=doc.get("team", ""), + goals=goals, + assists=assists, + xg=xg, + xa=xa, + minutes=int(agg.get("minutes", 0)), + xg_xa=xg + xa, + g_a=goals + assists, + s_final=float(scores.get("s_final", 0)), + xratio=scores.get("sleeper_ratio"), + flag=scores.get("sleeper_flag"), ) ) return ScatterDataOut(data=points) diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 970c443..6bf4523 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -4,7 +4,7 @@ from app.agent.agent import ChatAgent from app.agent.constants import GENERIC_ERROR -from app.api.modals.chat_modals import ChatHistory, ChatRequest, ChatResponse +from app.api.modals.chat_modals import ChatHistory, ChatRequest, ChatResponse, ToolCallOut from app.dependencies import get_agent router = APIRouter() @@ -21,7 +21,13 @@ async def chat(body: ChatRequest, agent: ChatAgent | None = Depends(get_agent)) except Exception: logger.exception("Chat request failed for session %s", body.session_id) return ChatResponse(answer=GENERIC_ERROR, session_id=body.session_id, degraded=True) - return ChatResponse(answer=result.answer, session_id=body.session_id, degraded=result.degraded) + return ChatResponse( + answer=result.answer, + session_id=body.session_id, + degraded=result.degraded, + tool_calls=[ToolCallOut(name=c.name, rows=c.rows) for c in result.tool_calls], + uncited=result.uncited, + ) @router.get("/sessions/{session_id}", response_model=ChatHistory) diff --git a/backend/app/api/meta.py b/backend/app/api/meta.py new file mode 100644 index 0000000..b99a463 --- /dev/null +++ b/backend/app/api/meta.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, Depends + +from app.agent.providers import PROVIDERS +from app.api.modals.meta_modals import AgentMetaOut, MetaOut +from app.config import settings +from app.dependencies import get_repo +from app.infrastructure.mongo_repository import MongoRepository + +router = APIRouter() + + +def _model_name() -> str: + provider = PROVIDERS.get(settings.llm_provider) + return settings.llm_model or (provider.default_model if provider else "") or "unknown" + + +@router.get("", response_model=MetaOut) +def get_meta(season: str | None = None, repo: MongoRepository = Depends(get_repo)) -> MetaOut: + """Player count, last fetch time and stored seasons, plus the chat model settings.""" + season_ = season or settings.season + return MetaOut( + players_total=repo.count_players(season_), + last_updated=repo.last_updated(season_), + seasons=repo.list_seasons(), + agent=AgentMetaOut(model=_model_name(), max_tool_calls=settings.agent_max_tool_iterations), + ) diff --git a/backend/app/api/modals/analysis_modals.py b/backend/app/api/modals/analysis_modals.py index 57681db..b9220f6 100644 --- a/backend/app/api/modals/analysis_modals.py +++ b/backend/app/api/modals/analysis_modals.py @@ -7,8 +7,17 @@ class ScatterPoint(BaseModel): sofascore_player_id: str | None name: str position: str + team: str = "" + goals: int = 0 + assists: int = 0 + xg: float = 0.0 + xa: float = 0.0 + minutes: int = 0 xg_xa: float g_a: float + s_final: float = 0.0 + xratio: float | None = None + flag: str | None = None class ScatterDataOut(BaseModel): diff --git a/backend/app/api/modals/chat_modals.py b/backend/app/api/modals/chat_modals.py index b32032c..3f729c6 100644 --- a/backend/app/api/modals/chat_modals.py +++ b/backend/app/api/modals/chat_modals.py @@ -8,11 +8,18 @@ class ChatRequest(BaseModel): message: str = Field(min_length=1, max_length=2000) -# No field can carry a tool trace: tool names, arguments and rows never leave the server. +class ToolCallOut(BaseModel): + name: str + rows: int + + +# The trace is tool names and row counts only: arguments and row contents never leave the server. class ChatResponse(BaseModel): answer: str session_id: str degraded: bool = False + tool_calls: list[ToolCallOut] = [] + uncited: list[str] = [] class ChatTurn(BaseModel): diff --git a/backend/app/api/modals/meta_modals.py b/backend/app/api/modals/meta_modals.py new file mode 100644 index 0000000..38aeaf7 --- /dev/null +++ b/backend/app/api/modals/meta_modals.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + + +class AgentMetaOut(BaseModel): + model: str + max_tool_calls: int + + +class MetaOut(BaseModel): + """Dataset status for the app header and the chat header.""" + + players_total: int + last_updated: str | None + seasons: list[str] + agent: AgentMetaOut diff --git a/backend/app/api/modals/player_modals.py b/backend/app/api/modals/player_modals.py index 04a6345..93ceaa2 100644 --- a/backend/app/api/modals/player_modals.py +++ b/backend/app/api/modals/player_modals.py @@ -76,7 +76,7 @@ class CompetitionOut(BaseModel): class AggregatedScoresOut(BaseModel): - """Season-level fantasy scores including underprediction analysis across all competitions.""" + """Season-level fantasy scores, underprediction analysis and confidence tier.""" offensive: float defensive: float @@ -84,6 +84,7 @@ class AggregatedScoresOut(BaseModel): s_final: float underpredicted_ratio: float | None underpredicted_flag: str | None + confidence: float = 1.0 class PlayerOut(BaseModel): diff --git a/backend/app/api/players.py b/backend/app/api/players.py index c758f10..f717117 100644 --- a/backend/app/api/players.py +++ b/backend/app/api/players.py @@ -14,7 +14,8 @@ ) from app.config import settings from app.dependencies import get_repo -from app.domain.metric_fields import FILTER_OPS, METRIC_FIELDS +from app.domain.metric_fields import FILTER_OPS, METRIC_FIELDS, SORT_FIELDS +from app.domain.scoring_engine import confidence_tier, effective_appearances from app.infrastructure.mongo_repository import MongoRepository from app.infrastructure.sofascore_client import SofascoreClient @@ -85,7 +86,10 @@ def _to_out(p: "PlayerDTO") -> PlayerOut: for c in p.competitions ], aggregated_stats=StatsOut(**p.aggregated_stats.__dict__), - aggregated_scores=AggregatedScoresOut(**p.aggregated_scores.__dict__), + aggregated_scores=AggregatedScoresOut( + **p.aggregated_scores.__dict__, + confidence=confidence_tier(effective_appearances(p.aggregated_stats)), + ), low_sample_size=p.low_sample_size, last_updated=p.last_updated, ) @@ -124,10 +128,10 @@ def list_players( stats_view: 'all' | 'club' | 'national' | When set, re-aggregates and re-scores each player from matching competitions only. - sort_by: any allowlisted metric (see metric_fields.METRIC_FIELDS); defaults to s_final. + sort_by: any allowlisted metric, or xratio (see metric_fields.SORT_FIELDS); defaults to s_final. filters: JSON array of {field, op, value} clauses over allowlisted numeric metrics. """ - if sort_by not in METRIC_FIELDS: + if sort_by not in SORT_FIELDS: raise _err(f"Unknown sort field: {sort_by!r}.") parsed_filters = _parse_filters(filters) players, total = repo.get_players( diff --git a/backend/app/domain/metric_fields.py b/backend/app/domain/metric_fields.py index 6fe9bde..f4dfb23 100644 --- a/backend/app/domain/metric_fields.py +++ b/backend/app/domain/metric_fields.py @@ -25,6 +25,15 @@ METRIC_FIELDS: dict[str, tuple[str, str]] = {**_STATS_FIELDS, **_SCORE_FIELDS} +# Sort-only: nullable, so it is neither a filter metric nor an agent tool metric. +SORT_FIELDS: dict[str, tuple[str, str]] = { + **METRIC_FIELDS, + "xratio": ("scores", "underpredicted_ratio"), +} + +# DTO attribute -> stored MongoDB field, where the two differ. +_MONGO_ATTRS = {"underpredicted_ratio": "sleeper_ratio"} + FILTER_OPS: dict[str, str] = { "gte": "$gte", "lte": "$lte", @@ -41,15 +50,15 @@ def mongo_path(name: str) -> str: - """Return the MongoDB dotted path for an allowlisted metric name.""" - source, attr = METRIC_FIELDS[name] + """Return the MongoDB dotted path for an allowlisted metric or sort name.""" + source, attr = SORT_FIELDS[name] root = "aggregated_stats" if source == "stats" else "aggregated_scores" - return f"{root}.{attr}" + return f"{root}.{_MONGO_ATTRS.get(attr, attr)}" -def python_value(player: PlayerDTO, name: str) -> float: +def python_value(player: PlayerDTO, name: str) -> float | None: """Read a metric value off a PlayerDTO (used by the stats_view re-aggregation path).""" - source, attr = METRIC_FIELDS[name] + source, attr = SORT_FIELDS[name] obj = player.aggregated_stats if source == "stats" else player.aggregated_scores return getattr(obj, attr) diff --git a/backend/app/domain/scoring_engine.py b/backend/app/domain/scoring_engine.py index f1638a2..de6e9a3 100644 --- a/backend/app/domain/scoring_engine.py +++ b/backend/app/domain/scoring_engine.py @@ -10,6 +10,23 @@ } +def effective_appearances(stats: Stats) -> int: + """Appearance count, estimated as ceil(minutes/90) when missing or non-positive.""" + # ceil, not round: round can imply >90 min per appearance (1300 min -> 14 apps). + return stats.appearances if stats.appearances > 0 else math.ceil(stats.minutes / 90) + + +def confidence_tier(apps: int) -> float: + """Confidence multiplier: 0.15/0.50/0.80/1.00 for <5/5-14/15-19/20+ appearances.""" + if apps < 5: + return 0.15 + if apps < 15: + return 0.50 + if apps < 20: + return 0.80 + return 1.00 + + class ScoringEngine: def calculate(self, stats: Stats, position: str) -> Score: """Compute offensive/defensive/tactical scores and s_final. @@ -54,9 +71,7 @@ def calculate(self, stats: Stats, position: str) -> Score: raw_per90 = (offensive + defensive + tactical) / minutes_per_90 - # Legacy/partial records carry minutes but no appearance count; estimate from minutes. - # ceil, not round: round can imply >90 min per appearance (1300 min -> 14 apps). - apps = stats.appearances if stats.appearances > 0 else math.ceil(stats.minutes / 90) + apps = effective_appearances(stats) starter_bonus = 1.0 + 0.2 * min(1.0, stats.matches_started / apps) @@ -65,15 +80,6 @@ def calculate(self, stats: Stats, position: str) -> Score: late_mins = max(0.0, min(avg_mins, 90.0) - 59.0) * apps playing_time_bonus = early_mins * 0.001 + late_mins * 0.0015 - if apps < 5: - confidence = 0.15 - elif apps < 15: - confidence = 0.50 - elif apps < 20: - confidence = 0.80 - else: - confidence = 1.00 - - s_final = raw_per90 * starter_bonus * confidence + playing_time_bonus + s_final = raw_per90 * starter_bonus * confidence_tier(apps) + playing_time_bonus return Score(offensive=offensive, defensive=defensive, tactical=tactical, s_final=s_final) diff --git a/backend/app/infrastructure/mongo_repository.py b/backend/app/infrastructure/mongo_repository.py index 0d954af..b0f80d9 100644 --- a/backend/app/infrastructure/mongo_repository.py +++ b/backend/app/infrastructure/mongo_repository.py @@ -22,6 +22,13 @@ from app.infrastructure.text_utils import normalize_text +def _substring(text: str) -> dict | None: + """Match a norm_* field by substring, or None when the text holds nothing searchable.""" + normalized = normalize_text(text) + # An empty term escapes to an empty regex, which matches every document rather than none. + return {"$regex": re.escape(normalized)} if normalized else None + + def _stats_to_dict(stats: Stats) -> dict: return asdict(stats) @@ -327,16 +334,15 @@ def get_players( bio_query["position"] = position if nationality: bio_query["nationality"] = nationality - if name: - # The name is user text: escape it so "." cannot match every player. - bio_query["name"] = {"$regex": re.escape(name), "$options": "i"} + if name and (clause := _substring(name)): + bio_query["norm_name"] = clause stats_query: dict = {"season": season} if bio_query: bio_ids = [d["_id"] for d in self._player_bios.find(bio_query, {"_id": 1})] stats_query["player_bio_id"] = {"$in": bio_ids} - if team: - stats_query["team"] = team + if team and (clause := _substring(team)): + stats_query["norm_team"] = clause if underpredicted_flag: stats_query["aggregated_scores.sleeper_flag"] = underpredicted_flag @@ -359,8 +365,10 @@ def get_players( for p in players_all if all(python_matches(p, f["field"], f["op"], f["value"]) for f in filters) ] - reverse = order == "desc" - players_all.sort(key=lambda p: python_value(p, sort_by), reverse=reverse) + # Nulls (e.g. xratio with no G+A) always sort last. + ranked = [p for p in players_all if python_value(p, sort_by) is not None] + ranked.sort(key=lambda p: python_value(p, sort_by), reverse=order == "desc") + players_all = ranked + [p for p in players_all if python_value(p, sort_by) is None] total = len(players_all) skip = (page - 1) * page_size return players_all[skip : skip + page_size], total @@ -411,6 +419,13 @@ def get_competition_list(self, season: str) -> dict[str, list[str]]: out.setdefault(t, []).append(doc["_id"]["name"]) return out + def matching_teams(self, season: str, team: str) -> list[str]: + """Distinct team names the text matches, so a caller can spot an ambiguous name.""" + clause = _substring(team) + if clause is None: + return [] + return sorted(self._player_stats.distinct("team", {"season": season, "norm_team": clause})) + def get_player(self, player_id: str, season: str) -> PlayerDTO | None: bio = self._player_bios.find_one({"sofascore_player_id": player_id}) if bio is None: @@ -430,6 +445,11 @@ def get_scatter_data(self, season: str) -> list[dict]: "aggregated_stats.xa": 1, "aggregated_stats.goals": 1, "aggregated_stats.assists": 1, + "aggregated_stats.minutes": 1, + "aggregated_scores.s_final": 1, + "aggregated_scores.sleeper_ratio": 1, + "aggregated_scores.sleeper_flag": 1, + "team": 1, }, ) ) @@ -449,7 +469,9 @@ def get_scatter_data(self, season: str) -> list[dict]: "sofascore_player_id": bio.get("sofascore_player_id"), "name": bio.get("name", ""), "position": bio.get("position", ""), + "team": s.get("team", ""), "aggregated_stats": s.get("aggregated_stats", {}), + "aggregated_scores": s.get("aggregated_scores", {}), } ) return result @@ -474,6 +496,22 @@ def list_fetched_leagues(self) -> list[dict]: for d in self._league_meta.find({}, {"competition": 1, "season": 1, "updated_at": 1}) ] + def count_players(self, season: str) -> int: + return self._player_stats.count_documents({"season": season}) + + def list_seasons(self) -> list[str]: + """Stored seasons, newest first.""" + return sorted(self._player_stats.distinct("season"), reverse=True) + + def last_updated(self, season: str) -> str | None: + """Latest league fetch time for a season, as an ISO string.""" + times = [ + d["updated_at"] + for d in self._league_meta.find({"season": season}, {"updated_at": 1}) + if d.get("updated_at") + ] + return max(times, default=None) + def set_league_total_matches(self, competition: str, season: str, total_matches: int) -> None: """Persist total matches played for a (competition, season) pair (upsert).""" self._league_meta.update_one( diff --git a/backend/app/main.py b/backend/app/main.py index 87a551f..0d49632 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ from app import dependencies from app.agent.agent import build_agent -from app.api import analysis, chat, fetch, players +from app.api import analysis, chat, fetch, meta, players from app.config import settings from app.infrastructure.mongo_repository import MongoRepository from app.logging_config import configure_logging @@ -44,6 +44,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.include_router(players.router, prefix="/v1/players") app.include_router(analysis.router, prefix="/v1/analysis") app.include_router(chat.router, prefix="/v1/chat") +app.include_router(meta.router, prefix="/v1/meta") # Re-export for backward compatibility with tests that import from app.main diff --git a/backend/tests/agent/test_agent.py b/backend/tests/agent/test_agent.py index cb848b0..d00a7d6 100644 --- a/backend/tests/agent/test_agent.py +++ b/backend/tests/agent/test_agent.py @@ -258,3 +258,33 @@ def test_clearing_a_session_survives_a_storage_failure(): model=FakeToolCallingModel(responses=[]), repo=fake_repo(), checkpointer=checkpointer ) agent.clear("gone") # must not raise: the API returns 204 either way + + +@pytest.mark.asyncio +async def test_answer_reports_tool_names_and_row_counts_only(): + scripted = [ + AIMessage( + content="", + tool_calls=[{"name": "attacking", "args": {"metric": "goals"}, "id": "t1"}], + ), + AIMessage(content="Player A leads with 10 goals."), + ] + res = await _agent(scripted, fake_repo(rows=[fake_player()])).answer("top?", session_id="t1") + assert [(c.name, c.rows) for c in res.tool_calls] == [("attacking", 1)] + assert res.uncited == [] + + +@pytest.mark.asyncio +async def test_answer_lists_uncited_figures(): + scripted = [ + AIMessage( + content="", + tool_calls=[{"name": "attacking", "args": {"metric": "goals"}, "id": "t2"}], + ), + AIMessage(content="Player A scored 41 goals."), + ] + res = await _agent(scripted, fake_repo(rows=[fake_player(goals=10)])).answer( + "top?", session_id="t2" + ) + assert res.degraded is True + assert res.uncited == ["41"] diff --git a/backend/tests/agent/test_tools_base.py b/backend/tests/agent/test_tools_base.py index 4ad7e31..c1c85a8 100644 --- a/backend/tests/agent/test_tools_base.py +++ b/backend/tests/agent/test_tools_base.py @@ -34,9 +34,10 @@ def _player(name="Player A", goals=10): ) -def _repo(players): +def _repo(players, teams=()): repo = MagicMock() repo.get_players.return_value = (players, len(players)) + repo.matching_teams.return_value = list(teams) return repo @@ -106,3 +107,26 @@ def test_the_built_tool_offers_both_sort_directions(): tool = build_metric_tool(_repo([]), name="attacking", description="d", metrics=["goals"]) schema = tool.args_schema.model_json_schema() assert set(schema["properties"]["order"]["enum"]) == {"asc", "desc"} + + +def test_an_ambiguous_team_name_is_reported_instead_of_merging_clubs(): + repo = _repo([_player()], teams=["Manchester City", "Manchester United"]) + rows = run_metric_query(repo, FAMILY, MetricQuery(metric="goals", team="Manchester")) + assert len(rows) == 1 + assert "Manchester City" in rows[0]["error"] + assert "Manchester United" in rows[0]["error"] + repo.get_players.assert_not_called() + + +def test_a_team_name_matching_one_club_still_queries(): + repo = _repo([_player()], teams=["Manchester City"]) + rows = run_metric_query(repo, FAMILY, MetricQuery(metric="goals", team="cit")) + assert "error" not in rows[0] + repo.get_players.assert_called_once() + + +def test_no_team_filter_skips_the_ambiguity_check(): + repo = _repo([_player()]) + rows = run_metric_query(repo, FAMILY, MetricQuery(metric="goals")) + assert "error" not in rows[0] + repo.matching_teams.assert_not_called() diff --git a/backend/tests/api/test_chat_api.py b/backend/tests/api/test_chat_api.py index 5752a7d..45bba4e 100644 --- a/backend/tests/api/test_chat_api.py +++ b/backend/tests/api/test_chat_api.py @@ -5,7 +5,7 @@ from fastapi.testclient import TestClient from app import dependencies, main -from app.agent.agent import ChatResult +from app.agent.agent import ChatResult, ToolCallSummary from app.agent.constants import GENERIC_ERROR from app.main import app @@ -33,7 +33,13 @@ def test_chat_returns_the_answer_only(client, agent): agent.answer = AsyncMock(return_value=ChatResult(answer="Player A leads.", used_tools=True)) r = client.post("/v1/chat", json={"session_id": "s1", "message": "top scorer?"}) assert r.status_code == 200 - assert r.json() == {"answer": "Player A leads.", "session_id": "s1", "degraded": False} + assert r.json() == { + "answer": "Player A leads.", + "session_id": "s1", + "degraded": False, + "tool_calls": [], + "uncited": [], + } agent.answer.assert_awaited_once_with("top scorer?", session_id="s1") @@ -93,7 +99,9 @@ def client_without_agent(): def test_chat_without_an_agent_returns_the_generic_message(client_without_agent): r = client_without_agent.post("/v1/chat", json={"session_id": "s1", "message": "hi"}) assert r.status_code == 200 - assert r.json() == {"answer": GENERIC_ERROR, "session_id": "s1", "degraded": True} + assert r.json()["answer"] == GENERIC_ERROR + assert r.json()["degraded"] is True + assert r.json()["tool_calls"] == [] def test_session_endpoints_without_an_agent_do_not_fail(client_without_agent): @@ -118,3 +126,13 @@ def test_startup_survives_an_agent_that_cannot_be_built(): finally: app.router.lifespan_context = original dependencies._repo = dependencies._mode_factory = dependencies._agent = None + + +def test_chat_returns_the_tool_trace_without_arguments(client, agent): + result = ChatResult( + answer="x", used_tools=True, tool_calls=[ToolCallSummary("attacking", 3)], uncited=["41"] + ) + agent.answer = AsyncMock(return_value=result) + body = client.post("/v1/chat", json={"session_id": "s1", "message": "q"}).json() + assert body["tool_calls"] == [{"name": "attacking", "rows": 3}] + assert body["uncited"] == ["41"] diff --git a/backend/tests/api/test_meta.py b/backend/tests/api/test_meta.py new file mode 100644 index 0000000..a2c60b8 --- /dev/null +++ b/backend/tests/api/test_meta.py @@ -0,0 +1,39 @@ +import mongomock +from fastapi.testclient import TestClient + +from app.config import settings +from app.infrastructure.mongo_repository import MongoRepository +from app.main import app, get_repo + +from .test_players import _make_player, _noop_lifespan + + +def _client(repo: MongoRepository) -> TestClient: + app.dependency_overrides[get_repo] = lambda: repo + app.router.lifespan_context = _noop_lifespan + return TestClient(app) + + +def test_meta_on_an_empty_database() -> None: + with _client(MongoRepository(mongomock.MongoClient())) as c: + body = c.get("/v1/meta").json() + app.dependency_overrides.clear() + assert body["players_total"] == 0 + assert body["last_updated"] is None + assert body["seasons"] == [] + assert body["agent"]["max_tool_calls"] == settings.agent_max_tool_iterations + assert body["agent"]["model"] + + +def test_meta_counts_players_and_lists_seasons() -> None: + repo = MongoRepository(mongomock.MongoClient()) + repo.upsert_player(_make_player("1", "2025-2026")) + repo.upsert_player(_make_player("2", "2025-2026")) + repo.upsert_player(_make_player("1", "2024-2025")) + repo.set_league_total_matches("England Premier League", "2025-2026", 10) + with _client(repo) as c: + body = c.get("/v1/meta?season=2025-2026").json() + app.dependency_overrides.clear() + assert body["players_total"] == 2 + assert body["seasons"] == ["2025-2026", "2024-2025"] + assert body["last_updated"].startswith("20") diff --git a/backend/tests/api/test_players.py b/backend/tests/api/test_players.py index b6e1ae8..0a1e393 100644 --- a/backend/tests/api/test_players.py +++ b/backend/tests/api/test_players.py @@ -196,3 +196,60 @@ def test_filter_bad_op_422(client_with_player: TestClient) -> None: def test_filter_non_numeric_value_422(client_with_player: TestClient) -> None: resp = client_with_player.get('/v1/players?filters=[{"field":"goals","op":"gte","value":"x"}]') assert resp.status_code == 422 + + +def test_list_players_filter_by_team_partial(client_with_player: TestClient) -> None: + resp = client_with_player.get("/v1/players?team=rsen") + assert resp.status_code == 200 + assert resp.json()["total"] == 1 + + +def test_list_players_filter_by_team_case_insensitive(client_with_player: TestClient) -> None: + resp = client_with_player.get("/v1/players?team=ARSENAL") + assert resp.status_code == 200 + assert resp.json()["total"] == 1 + + +def test_list_players_team_and_name_are_combined_with_and(client_with_player: TestClient) -> None: + both = client_with_player.get("/v1/players?team=arsenal&name=test") + assert both.status_code == 200 + assert both.json()["total"] == 1 + + # The team matches but the name does not, so the AND must reject the player. + one = client_with_player.get("/v1/players?team=arsenal&name=nobody") + assert one.status_code == 200 + assert one.json()["total"] == 0 + + +def test_player_exposes_confidence(client_with_player: TestClient) -> None: + # 900 minutes and no appearance count -> 10 estimated apps -> the 0.5 tier. + resp = client_with_player.get("/v1/players/1") + assert resp.json()["aggregated_scores"]["confidence"] == 0.5 + + +def test_sort_by_xratio(client: TestClient) -> None: + repo = app.dependency_overrides[get_repo]() + for pid, ratio in (("1", 1.3), ("2", 1.8), ("3", None)): + p = _make_player(pid) + p.aggregated_scores.underpredicted_ratio = ratio + repo.upsert_player(p) + desc = client.get("/v1/players?sort_by=xratio").json()["data"] + ids = [d["sofascore_player_id"] for d in desc] + assert ids[:2] == ["2", "1"] + asc = client.get("/v1/players?sort_by=xratio&order=asc&stats_view=club").json()["data"] + assert [d["sofascore_player_id"] for d in asc] == ["1", "2", "3"] # nulls last + + +def test_xratio_is_not_a_filter_field(client_with_player: TestClient) -> None: + resp = client_with_player.get('/v1/players?filters=[{"field":"xratio","op":"gte","value":1}]') + assert resp.status_code == 422 + + +def test_scatter_points_carry_the_panel_fields(client_with_player: TestClient) -> None: + point = client_with_player.get("/v1/analysis/scatter").json()["data"][0] + assert point["team"] == "Arsenal" + assert (point["goals"], point["assists"], point["minutes"]) == (5, 3, 900) + assert point["xg"] == pytest.approx(4.0) and point["xa"] == pytest.approx(2.5) + assert point["s_final"] == pytest.approx(4.06) + assert point["xratio"] == pytest.approx(1.3) + assert point["flag"] == "HIGH_VALUE" diff --git a/backend/tests/domain/test_scoring_engine.py b/backend/tests/domain/test_scoring_engine.py index 0b3f229..f55f1dc 100644 --- a/backend/tests/domain/test_scoring_engine.py +++ b/backend/tests/domain/test_scoring_engine.py @@ -1,7 +1,7 @@ import pytest from app.domain.models import Stats -from app.domain.scoring_engine import ScoringEngine +from app.domain.scoring_engine import ScoringEngine, confidence_tier, effective_appearances @pytest.fixture @@ -211,3 +211,16 @@ def test_negative_appearances_treated_as_missing(engine: ScoringEngine) -> None: corrupt = engine.calculate(Stats(goals=5, minutes=900, appearances=-3), "FW") missing = engine.calculate(Stats(goals=5, minutes=900, appearances=0), "FW") assert corrupt.s_final == pytest.approx(missing.s_final, rel=1e-9) + + +@pytest.mark.parametrize( + ("apps", "expected"), [(0, 0.15), (4, 0.15), (5, 0.5), (14, 0.5), (15, 0.8), (20, 1.0)] +) +def test_confidence_tier_is_a_pure_lookup(apps: int, expected: float) -> None: + assert confidence_tier(apps) == expected + + +def test_effective_appearances_estimates_from_minutes() -> None: + assert effective_appearances(Stats(minutes=900)) == 10 + assert effective_appearances(Stats(minutes=900, appearances=12)) == 12 + assert effective_appearances(Stats()) == 0 diff --git a/backend/tests/infrastructure/test_mongo_repository.py b/backend/tests/infrastructure/test_mongo_repository.py index 511aea2..43a1064 100644 --- a/backend/tests/infrastructure/test_mongo_repository.py +++ b/backend/tests/infrastructure/test_mongo_repository.py @@ -367,3 +367,95 @@ def test_a_name_filter_is_matched_literally_not_as_a_regex(repo: MongoRepository repo.upsert_player(dataclasses.replace(_make_player("2"), name="Adrien Truffert")) assert repo.get_players(season="2025-2026", name=".")[1] == 0 assert repo.get_players(season="2025-2026", name="O'Reilly")[1] == 1 + + +def test_get_players_team_filter_is_a_case_insensitive_substring(repo: MongoRepository) -> None: + player = _make_player("1") + player.team = "Manchester City" + repo.upsert_player(player) + for query in ("Cit", "cit", "CITY", "manchester"): + _, total = repo.get_players(season="2025-2026", team=query) + assert total == 1, query + + +def test_get_players_team_filter_ignores_accents(repo: MongoRepository) -> None: + player = _make_player("1") + player.team = "FC Bayern München" + repo.upsert_player(player) + _, total = repo.get_players(season="2025-2026", team="munchen") + assert total == 1 + + +def test_get_players_team_substring_still_excludes_other_teams(repo: MongoRepository) -> None: + city = _make_player("1") + city.team = "Manchester City" + united = _make_player("2") + united.team = "Manchester United" + repo.upsert_player(city) + repo.upsert_player(united) + players, total = repo.get_players(season="2025-2026", team="cit") + assert total == 1 + assert players[0].team == "Manchester City" + + +def test_get_players_team_filter_treats_regex_characters_as_text(repo: MongoRepository) -> None: + player = _make_player("1") + player.team = "Manchester City" + repo.upsert_player(player) + _, total = repo.get_players(season="2025-2026", team=".*") + assert total == 0 + + +def test_get_players_name_and_team_are_combined_with_and(repo: MongoRepository) -> None: + kane = _make_player("1") + kane.name = "Harry Kane" + kane.team = "FC Bayern München" + maguire = _make_player("2") + maguire.name = "Harry Maguire" + maguire.team = "Manchester United" + repo.upsert_player(kane) + repo.upsert_player(maguire) + + players, total = repo.get_players(season="2025-2026", name="Harry", team="bayern") + assert total == 1 + assert players[0].name == "Harry Kane" + + # A player matching only one of the two must not come back: this is AND, never OR. + _, miss = repo.get_players(season="2025-2026", name="Harry Kane", team="Manchester") + assert miss == 0 + + +def test_get_players_name_filter_ignores_accents(repo: MongoRepository) -> None: + player = _make_player("1") + player.name = "Julián Álvarez" + repo.upsert_player(player) + _, total = repo.get_players(season="2025-2026", name="julian alvarez") + assert total == 1 + + +def test_get_players_blank_team_is_not_a_filter(repo: MongoRepository) -> None: + repo.upsert_player(_make_player("1")) + # An empty regex would match every document, so whitespace must not become a filter. + _, total = repo.get_players(season="2025-2026", team=" ") + assert total == 1 + + +def test_get_players_blank_name_is_not_a_filter(repo: MongoRepository) -> None: + repo.upsert_player(_make_player("1")) + _, total = repo.get_players(season="2025-2026", name=" ") + assert total == 1 + + +def test_matching_teams_lists_every_team_the_text_hits(repo: MongoRepository) -> None: + city = _make_player("1") + city.team = "Manchester City" + united = _make_player("2") + united.team = "Manchester United" + repo.upsert_player(city) + repo.upsert_player(united) + assert repo.matching_teams("2025-2026", "manchester") == [ + "Manchester City", + "Manchester United", + ] + assert repo.matching_teams("2025-2026", "cit") == ["Manchester City"] + assert repo.matching_teams("2025-2026", " ") == [] diff --git a/docs/superpowers/specs/2026-09-24-ui-redesign-design.md b/docs/superpowers/specs/2026-09-24-ui-redesign-design.md new file mode 100644 index 0000000..80174c7 --- /dev/null +++ b/docs/superpowers/specs/2026-09-24-ui-redesign-design.md @@ -0,0 +1,103 @@ +# UI redesign to match `sketches/` + +## Context +The `sketches/` folder has 7 high-fidelity screens: Player details, Player modal, Compare, xGI Outliers, Scatter Plot, Ask AI, and System states. They define a new visual language: a near-black green-tinted theme, an emerald accent, amber/blue/red signal colors, Inter for text, and a monospace font for numbers and labels. The current UI uses gray + indigo, emoji icons, no fonts, and donut charts. It also lacks several elements the sketches show. Goal: rebuild the frontend so it matches the sketches exactly. Add only the backend data these elements need. + +Decisions already made by the user: +- **Scope:** full, frontend + backend in one branch. +- **Dependencies:** `@fontsource/inter`, `@fontsource/jetbrains-mono`, `lucide-react`. +- **Chat thumbs up/down:** omit. +- **Avatars:** initials, no photos. Keep the floating chat bubble (restyled). + +## Setup +- Branch `dev/ui-redesign` stacked on the current tip. Leave the uncommitted `.gitignore` change for the user. +- Take a DB snapshot first (`db-snapshot` skill). +- Commit this plan as the spec in `docs/superpowers/specs/2026-09-24-ui-redesign-design.md`. + +## Part A: backend (TDD, mongomock tests next to the existing ones) +1. **`GET /v1/meta?season=`** in a new `app/api/meta.py`, registered in `main.py`. Returns: + - `players_total` + - `last_updated`: the max `updated_at` from `repo.get_fetched()` + - `seasons`: distinct `player_stats.season` + - `agent`: `{model, max_tool_calls}` from `config.py`. `model` comes from the resolved provider default in `agent/providers.py`. + This feeds the header status (`● 2,481 players · updated 22 Jun`), the season dropdown, and the Ask AI header (`gemini-… · 8 tool calls max`). +2. **Confidence:** extract `confidence_tier(apps)` from `ScoringEngine.calculate` (`domain/scoring_engine.py:68-75`) as a pure function. Expose `confidence` on `aggregated_scores` in `PlayerOut`, computed in the API layer from `aggregated_stats`. No DB migration. +3. **Chat trace** in `ChatResponse` (`api/modals/chat_modals.py`): + - `tool_calls: [{name, rows}]`, built in `ChatAgent.answer` (`agent/agent.py`) from the `AIMessage.tool_calls` and `ToolMessage` of the current turn. Names and row counts only, never arguments or row contents. + - `uncited: list[str]` from `uncited_numbers()`, used for the specific warning text. + - This reverses the deliberate "No field can carry a tool trace" comment at `chat_modals.py:11`. The user approved this under "Full scope". Update the comment. +4. **Scatter points** (`api/analysis.py`, `analysis_modals.py`): add `team`, `goals`, `assists`, `xg`, `xa`, `minutes`, `s_final`, `xratio`, `flag`. The frontend does the axis selection, position filter and minutes filter on the client. +5. **Sort by xRatio:** allow `sort_by=xratio` on `GET /v1/players` (maps to `aggregated_scores.sleeper_ratio`). Extend `metric_fields.py` or the repo sort mapping, including the Python-sort path for `stats_view` (`mongo_repository.py:349-367`). Outliers sorts descending for "Due to score" and ascending for "Overperforming". + +## Part B: frontend foundation +- **Deps:** `npm i @fontsource/inter @fontsource/jetbrains-mono lucide-react`. Import the fonts in `main.tsx`. +- **`tailwind.config.js` tokens:** + - `bg`, `surface`, `surface-2`, `border` (dark green-black) + - `accent` (emerald), `warn` (amber: Due to score), `info` (blue: TOTW, player B wins), `danger` (red) + - `muted` text + - `fontFamily.sans=Inter`, `fontFamily.mono=JetBrains Mono` +- **`index.css`:** body background, tabular numbers. +- **New `src/components/ui/`** primitives, reused on every page: + - `Avatar` (initials) + - `PosBadge` + - `SignalBadge`: DUE TO SCORE / OVERPERFORMING / ☆ TOTW ×N + - `ScoreBar`: value plus thin bar under it + - `Segmented`: All/Club/National, GK/DF/MF/FW, outlier tabs + - `Select` and `IconInput`: search / shield / flag icons + - `FilterChip`: `minutes ≥ 900 ×` + - `Pagination`: `Showing 1–14 of N`, Prev, `1 / 50`, Next + - `StatRow`, `StackedBar` + legend (replaces the donut charts) + - `PageHeader`: title + mono subtitle + right-side meta + - `DataTable`: sortable header style, row hover, dimmed-while-loading +- **Shell (`App.tsx` → new `components/AppShell.tsx`):** + - Logo `FOOTBALL ANALYTICS` (second word in accent) and the tab bar with an accent underline. + - Right side: meta status + season `Select`. On the Ask AI tab it shows the model status + `New chat` instead. + - A lifted `season` state goes to every API call (`api/*.ts` gain `season`). + - Navigation helpers instead of a router: `goTo(tab, payload)` for "Compare with…" (preset player A), "Open profile", and "Open in Player details". +- **States (sketch 07):** + - `EmptyDatabase` replaces `SeedPrompt`: DB icon, the fetch_cli code block with Copy, `Open API docs ↗` (`${API}/docs`), and `Check again`. The header shows amber `0 players · database empty`. + - `BackendUnreachable`: only the content region, auto-retry every 10 s + manual retry. + - `NoResults`: lists the active filters, with `clear all filters` / `remove last clause`. + - Loading: skeleton on first load, then dimmed rows (keep the existing `requestId` stale-drop logic). + +## Part C: pages (one commit each) +- **Player details** (`pages/PlayerDetails.tsx`, `FilterBar.tsx`, `PlayerTable.tsx`): + - Header: formula subtitle and `N results`. + - Filter row: name / position / team / nationality / flags, and `+ Add metric filter` (it adds a popover row; applied clauses render as removable `FilterChip`s). + - Second row: `STATS VIEW` segmented, competition select, chips, `sorted by …` label. + - Columns: #, Player (avatar + name + nationality), Pos, Team, Fantasy Score (ScoreBar), Apps, G, A, xG, xA, Min, Rating, Signal (flag + TOTW badges). +- **Player modal** (`PlayerModal.tsx`, `PlayerCard.tsx`): + - Header with TOTW + xRatio chips. + - Score panel: Fantasy Score, Offensive, Defensive, Tactical, Confidence bar. + - Aggregate stats list (left). Per-competition rows with CLUB/NT tags (right). + - Goal types and shot outcome as `StackedBar`s. Blocked shots = total − on − off, as today. + - `Show all 35 metrics` expander, `Compare with…` (→ Compare with A preset), and `last updated`. + - Keep the refresh-bio behaviour. +- **Compare** (`pages/Compare.tsx`): + - Two player cards (A / VS / B) with Fantasy Score and a swap/change button that opens the existing debounced search. + - Mirrored bar table: A value + bar | metric | bar + B value | Δ. The winner's bar is accent for A and info-blue for B; the loser's bar is muted. Δ is colored by "higher is better", and the cards are inverted. + - Rows as in sketch 03. +- **xGI Outliers** (`pages/Sleepers.tsx`): + - Two rule cards (amber and green left border, formula + text), the segmented tab, `N players flagged · sorted by xRatio`. + - Columns: …, xRatio (amber ScoreBar), Apps, G, A, xG, xA (accent), Min, Score, Signal + `+7.7 xGI` delta. + - The rule text uses the thresholds from `sleeper_detector.py`. The sketch's `1.25×` matches the code: a ratio below 0.8 means G+A > 1.25 × (xG+xA). +- **Scatter** (`ScatterPage.tsx`, `ScatterPlot.tsx`): + - Controls: X/Y metric selects (xG+xA, xG, xA / G+A, Goals, Assists), position segmented, `minutes ≥` input, `Highlight outliers` toggle. + - Recharts: the diagonal labelled `xGI = GI`, dots colored by flag (gray when in line or when highlighting is off), labels for the selected point and the most extreme outliers. + - Keep zoom and pan. + - Right column: Legend card, `SELECTED POINT` card (xG+xA, G+A, xRatio, Minutes, Fantasy Score, badge, `Open profile ↗` → modal), and the hint `Click any point to inspect the player`. +- **Ask AI** (`ChatPanel.tsx`, `ChatFullScreen.tsx`, `ChatWidget.tsx`): + - Accent user bubble on the right. The assistant message has a sparkle icon, a `RAN tool() tool() → N rows` chip row, markdown with a styled GFM table (xRatio column in amber), and an amber warning box built from `uncited`. + - Actions: `Copy`, `Open in Player details`. + - Static suggestion chips above the input. The input shows a `0 / 2000` counter and a `Send ↑` button, with the footer `Session history is kept for 7 days…`. + - The widget reuses the same restyled panel. + +## Docs +Invoke the `technical-writer` agent: CLAUDE.md (frontend pages, `/v1/meta`, chat trace fields), README, and the API docs. + +## Verification +- `backend/.venv\Scripts\python -m pytest` must be green, with new tests for meta, confidence tier, chat trace (fake model with tool calls), scatter fields and xratio sort. +- `npm run build` + `npm run lint` in `frontend/` (tsc + eslint). +- `docker compose build backend` and recreate the frontend (new node deps, anonymous volume). +- Use the `run` skill / Chrome: open each tab at 1440 px wide, screenshot it, and compare it side by side with the matching `sketches/0X-*.png`. Check the modal, the Compare flow from "Compare with…", scatter point selection, a live chat answer (trace + warning), empty-DB state (load an empty DB or point at an empty season), and backend-down state (stop the backend container). +- Run the `pre-pr-lint` skill before the PR. Open a PR to master, and merge only after the user approves with CI green. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index da3da81..ccb5dfb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "football-analytics-frontend", "version": "1.0.0", "dependencies": { + "@fontsource/inter": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "lucide-react": "^1.48.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -883,6 +886,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fontsource/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -3521,6 +3542,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.48.0.tgz", + "integrity": "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 512de1d..0103032 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,9 @@ "lint": "eslint ." }, "dependencies": { + "@fontsource/inter": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "lucide-react": "^1.48.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d6e215e..6c215f2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,25 +1,20 @@ -import { useState, useEffect } from 'react' -import { getPlayers } from './api/players' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { getMeta, type Meta } from './api/meta' +import { clearSession, getSessionId } from './api/chat' +import AppShell, { type Status } from './components/AppShell' +import ChatPanel from './components/ChatPanel' import ChatWidget from './components/ChatWidget' +import { BackendUnreachable, EmptyDatabase } from './components/states/SystemStates' +import { AppContext, type GoOptions, type Tab } from './context/AppContext' import ChatFullScreen from './pages/ChatFullScreen' -import SeedPrompt from './components/SeedPrompt' -import Rankings from './pages/Rankings' -import PlayerDetail from './pages/PlayerDetail' +import PlayerDetails from './pages/PlayerDetails' import Compare from './pages/Compare' import Sleepers from './pages/Sleepers' import ScatterPage from './pages/ScatterPage' -type Tab = 'rankings' | 'detail' | 'compare' | 'sleepers' | 'scatter' +const RETRY_MS = 10_000 -const TABS: { id: Tab; label: string }[] = [ - { id: 'rankings', label: 'Rankings' }, - { id: 'detail', label: 'Player Detail' }, - { id: 'compare', label: 'Compare' }, - { id: 'sleepers', label: 'Underpredicted' }, - { id: 'scatter', label: 'Scatter Plot' }, -] - -// One query param does not justify adding a router. The chat has no navbar tab by design. +// One query param does not justify adding a router. const fullScreenChat = new URLSearchParams(window.location.search).get('chat') === '1' export default function App() { @@ -27,60 +22,79 @@ export default function App() { } function Dashboard() { - const [tab, setTab] = useState('rankings') - const [isEmpty, setIsEmpty] = useState(null) - const [dbError, setDbError] = useState(false) + const [tab, setTab] = useState('players') + const [season, setSeason] = useState('') + const [meta, setMeta] = useState(null) + const [status, setStatus] = useState('loading') + const [tick, setTick] = useState(0) + const [compareSeed, setCompareSeed] = useState(null) + const [chatKey, setChatKey] = useState(0) + + const retry = useCallback(() => setTick((t) => t + 1), []) + const clearSeed = useCallback(() => setCompareSeed(null), []) + + useEffect(() => { + let live = true + getMeta(season || undefined) + .then((m) => { + if (!live) return + setMeta(m) + setStatus(m.seasons.length === 0 ? 'empty' : 'ok') + if (!season && m.seasons.length) setSeason(m.seasons[0]) + }) + .catch(() => live && setStatus('down')) + return () => { live = false } + }, [season, tick]) useEffect(() => { - getPlayers({ page_size: 1 }) - .then(r => setIsEmpty(r.total === 0)) - .catch(() => setDbError(true)) + if (status !== 'down') return + const t = setInterval(retry, RETRY_MS) + return () => clearInterval(t) + }, [status, retry]) + + const go = useCallback((next: Tab, opts?: GoOptions) => { + if (opts?.comparePlayerId) setCompareSeed(opts.comparePlayerId) + setTab(next) }, []) - return ( -
- + const newChat = async () => { + try { await clearSession(getSessionId()) } catch { /* the TTL removes the thread later */ } + setChatKey((k) => k + 1) + } -
- {dbError && ( -
- Cannot reach the backend. Make sure the server is running. -
- )} - {!dbError && isEmpty === null && ( -
- Checking database… -
- )} - {!dbError && isEmpty === true && } - {!dbError && isEmpty === false && ( + const ctx = useMemo(() => ({ season, meta, go }), [season, meta, go]) + const ready = status === 'ok' && season !== '' + + return ( + + + {status === 'down' && } + {status === 'empty' && } + {ready && ( <> - {tab === 'rankings' && } - {tab === 'detail' && } - {tab === 'compare' && } - {tab === 'sleepers' && } - {tab === 'scatter' && } + {tab === 'players' && } + {tab === 'compare' && ( + + )} + {tab === 'sleepers' && } + {tab === 'scatter' && } + {tab === 'chat' && ( +
+ +
+ )} )} -
- - {!dbError && isEmpty === false && } -
+ + {ready && tab !== 'chat' && } + ) } diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 1d45e7e..b7bfa48 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -5,10 +5,17 @@ export interface ChatTurn { content: string } +export interface ToolCall { + name: string + rows: number +} + export interface ChatResponse { answer: string session_id: string degraded: boolean + tool_calls: ToolCall[] + uncited: string[] } const SESSION_KEY = 'fa.chat.session_id' diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index af1b06c..13feed9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000' +export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000' export async function apiFetch(path: string, options?: RequestInit): Promise { const res = await fetch(`${API_URL}${path}`, { diff --git a/frontend/src/api/meta.ts b/frontend/src/api/meta.ts new file mode 100644 index 0000000..c898fb8 --- /dev/null +++ b/frontend/src/api/meta.ts @@ -0,0 +1,14 @@ +import { apiFetch, API_URL } from './client' + +export interface Meta { + players_total: number + last_updated: string | null + seasons: string[] + agent: { model: string; max_tool_calls: number } +} + +export function getMeta(season?: string): Promise { + return apiFetch(`/v1/meta${season ? `?season=${encodeURIComponent(season)}` : ''}`) +} + +export const API_DOCS_URL = `${API_URL}/docs` diff --git a/frontend/src/api/players.ts b/frontend/src/api/players.ts index 72febc0..8e711b4 100644 --- a/frontend/src/api/players.ts +++ b/frontend/src/api/players.ts @@ -32,9 +32,11 @@ export interface CompetitionEntry { export interface CompetitionList { club: string[]; national: string[] } export interface AggregatedScores extends Score { - underpredicted_ratio: number | null; underpredicted_flag: 'HIGH_VALUE' | 'OVERPERFORMING' | null + underpredicted_ratio: number | null; underpredicted_flag: Flag | null; confidence: number } +export type Flag = 'HIGH_VALUE' | 'OVERPERFORMING' + export interface Player { sofascore_player_id: string; name: string; season: string position: string; position_exact: string; team: string; nationality: string @@ -54,7 +56,7 @@ export interface FilterClause { field: string; op: FilterOp; value: number } // Allowlisted sortable/filterable metrics — mirrors backend metric_fields.METRIC_FIELDS. export const METRIC_OPTIONS: { value: string; label: string }[] = [ - { value: 's_final', label: 'S_final' }, + { value: 's_final', label: 'Fantasy Score' }, { value: 'goals', label: 'Goals' }, { value: 'assists', label: 'Assists' }, { value: 'xg', label: 'xG' }, @@ -122,7 +124,9 @@ export function serializeFilters(clauses: FilterClause[]): string { } export interface ScatterPoint { - sofascore_player_id: string | null; name: string; position: string; xg_xa: number; g_a: number + sofascore_player_id: string | null; name: string; position: string; team: string + goals: number; assists: number; xg: number; xa: number; minutes: number + xg_xa: number; g_a: number; s_final: number; xratio: number | null; flag: Flag | null } export interface ScatterData { data: ScatterPoint[] } @@ -136,14 +140,14 @@ export async function getPlayers(params: Record(`/v1/players${qs ? `?${qs}` : ''}`) } +const seasonQs = (season?: string) => (season ? `?season=${encodeURIComponent(season)}` : '') + export async function getPlayer(playerId: string, season?: string): Promise { - const qs = season ? `?season=${season}` : '' - return apiFetch(`/v1/players/${playerId}${qs}`) + return apiFetch(`/v1/players/${playerId}${seasonQs(season)}`) } export async function getScatterData(season?: string): Promise { - const qs = season ? `?season=${season}` : '' - return apiFetch(`/v1/analysis/scatter${qs}`) + return apiFetch(`/v1/analysis/scatter${seasonQs(season)}`) } export interface BioData { nationality: string; position_exact: string } @@ -153,6 +157,5 @@ export async function refreshBio(playerId: string): Promise { } export async function getPlayerCompetitions(season?: string): Promise { - const qs = season ? `?season=${season}` : '' - return apiFetch(`/v1/players/competitions${qs}`) + return apiFetch(`/v1/players/competitions${seasonQs(season)}`) } diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..ece8cfd --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,101 @@ +import type { ReactNode } from 'react' +import { Plus } from 'lucide-react' +import type { Meta } from '../api/meta' +import type { Tab } from '../context/AppContext' +import { shortDate } from '../lib/format' +import { Select } from './ui/Fields' + +export type Status = 'loading' | 'ok' | 'empty' | 'down' + +const TABS: { id: Tab; label: string }[] = [ + { id: 'players', label: 'Player details' }, + { id: 'compare', label: 'Compare' }, + { id: 'sleepers', label: 'xGI Outliers' }, + { id: 'scatter', label: 'Scatter Plot' }, + { id: 'chat', label: 'Ask AI' }, +] + +interface ShellProps { + tab: Tab + onTab: (t: Tab) => void + status: Status + meta: Meta | null + season: string + onSeason: (s: string) => void + onNewChat: () => void + children: ReactNode +} + +export default function AppShell({ tab, onTab, status, meta, season, onSeason, onNewChat, children }: ShellProps) { + return ( +
+
+
+
+ FOOTBALL ANALYTICS +
+ +
+ + {tab === 'chat' ? ( + + ) : ( + status === 'ok' && meta && meta.seasons.length > 0 && ( + + ) + )} +
+
+
+
{children}
+
+ ) +} + +function HeaderStatus({ tab, status, meta }: { tab: Tab; status: Status; meta: Meta | null }) { + const cls = 'flex items-center gap-2 whitespace-nowrap font-mono text-[11px]' + if (status === 'down') { + return backend unreachable + } + if (!meta) return null + if (tab === 'chat') { + return ( + + + {meta.agent.model}·{meta.agent.max_tool_calls} tool calls max + + ) + } + if (status === 'empty') { + return ( + + 0 players·database empty + + ) + } + const updated = shortDate(meta.last_updated) + return ( + + + {meta.players_total.toLocaleString('en-US')} players + {updated && <>·updated {updated}} + + ) +} diff --git a/frontend/src/components/ChatPanel.tsx b/frontend/src/components/ChatPanel.tsx index d16452c..48aa94c 100644 --- a/frontend/src/components/ChatPanel.tsx +++ b/frontend/src/components/ChatPanel.tsx @@ -1,13 +1,24 @@ import { useEffect, useRef, useState, type FormEvent } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' -import { clearSession, getSession, getSessionId, sendChat, type ChatTurn } from '../api/chat' +import { ArrowUp, Check, Copy, ExternalLink, Sparkles, TriangleAlert } from 'lucide-react' +import { getSession, getSessionId, sendChat, type ChatTurn, type ToolCall } from '../api/chat' +import { useApp } from '../context/AppContext' interface Turn extends ChatTurn { degraded?: boolean + tool_calls?: ToolCall[] + uncited?: string[] } const NETWORK_ERROR = 'Could not reach the server. Please try again.' +const MAX_CHARS = 2000 +const SUGGESTIONS = [ + 'Top 5 scorers this season', + 'Best value midfielders', + 'Compare Saka and Palmer', + 'Who is regressing?', +] export default function ChatPanel({ fullScreen = false }: { fullScreen?: boolean }) { const [turns, setTurns] = useState([]) @@ -26,9 +37,8 @@ export default function ChatPanel({ fullScreen = false }: { fullScreen?: boolean bottom.current?.scrollIntoView({ behavior: 'smooth' }) }, [turns, sending]) - async function send(e: FormEvent) { - e.preventDefault() - const message = draft.trim() + async function ask(text: string) { + const message = text.trim() if (!message || sending) return setDraft('') setTurns((prev) => [...prev, { role: 'user', content: message }]) @@ -37,7 +47,7 @@ export default function ChatPanel({ fullScreen = false }: { fullScreen?: boolean const res = await sendChat(message, getSessionId()) setTurns((prev) => [ ...prev, - { role: 'assistant', content: res.answer, degraded: res.degraded }, + { role: 'assistant', content: res.answer, degraded: res.degraded, tool_calls: res.tool_calls, uncited: res.uncited }, ]) } catch { // The backend answers 200 even when it fails, so this is a network error only. @@ -47,95 +57,182 @@ export default function ChatPanel({ fullScreen = false }: { fullScreen?: boolean } } - async function startNewChat() { - setTurns([]) - try { - await clearSession(getSessionId()) - } catch { - // The thread stays on the server; the user still gets a clean view. - } - } + const submit = (e: FormEvent) => { e.preventDefault(); ask(draft) } + const pad = fullScreen ? 'space-y-7' : 'space-y-4' return ( -
-
- {turns.length === 0 && !sending && ( -

- No messages yet. Try “Who are the top 5 scorers?” +

+ {fullScreen && ( +
+

Ask AI

+

+ Answers are assembled from live database queries. Anything the data cannot support is labelled.

+
+ )} + +
+ {turns.length === 0 && !sending && ( +

Ask about any player or metric in the database. I can rank, filter and compare.

+ )} + {turns.map((turn, i) => + turn.role === 'user' + ? + : , + )} + {sending && ( +
+ + + {[0, 150, 300].map((d) => ( + + ))} + +
)} - {turns.map((turn, i) => ( - - ))} - {sending &&

Thinking…

}
-
- setDraft(e.target.value)} - placeholder="Ask about a player or a metric..." - maxLength={2000} - aria-label="Your question" - className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm" - /> - -
- - +
+
+ {SUGGESTIONS.map((s) => ( + + ))} +
+
+ setDraft(e.target.value)} + placeholder="Ask about a player, a metric, or a comparison..." + maxLength={MAX_CHARS} + aria-label="Your question" + className={`min-w-0 flex-1 bg-transparent text-ink outline-none placeholder:text-dim ${fullScreen ? 'text-[15px]' : 'text-sm'}`} + /> + {fullScreen && {draft.length} / {MAX_CHARS}} + +
+ {fullScreen && ( +

Session history is kept for 7 days after the last message.

+ )} +
) } -function Bubble({ turn }: { turn: Turn }) { - const mine = turn.role === 'user' +function AiIcon() { return ( -
-
- {mine ? ( - {turn.content} - ) : ( - + + + + ) +} + +function UserBubble({ text, compact }: { text: string; compact: boolean }) { + return ( +
+
+ {text} +
+
+ ) +} + +function AssistantMessage({ turn, compact }: { turn: Turn; compact: boolean }) { + const { go } = useApp() + const [copied, setCopied] = useState(false) + const calls = turn.tool_calls ?? [] + const rows = calls.reduce((n, c) => n + c.rows, 0) + const uncited = turn.uncited ?? [] + + const copy = () => { + navigator.clipboard?.writeText(turn.content).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }).catch(() => {}) + } + + return ( +
+ {!compact && } +
+ {calls.length > 0 && ( +
+ RAN + {calls.map((c, i) => ( + {c.name}() + ))} + → {rows} rows +
)} + {turn.degraded && ( -
Not verified against the app's data.
+
+ + + {uncited.length > 0 + ? `${uncited.length === 1 ? 'One figure' : `${uncited.length} figures`} in this answer — ${uncited.join(', ')} — could not be matched to a stored row and may come from the model's own knowledge.` + : "This answer is not backed by the app's data."} + +
+ )} + {!compact && ( +
+ + +
)}
) } +// Ratio cells ("1.77×") are the xRatio column; they get the amber signal color. +const RATIO_CELL = /^\d+(\.\d+)?\s*[×x]$/ + // Answers come back as markdown. react-markdown escapes HTML, so no raw html is rendered. -const MARKDOWN_STYLES = [ - '[&_p]:mb-2 [&_p:last-child]:mb-0', - '[&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5 [&_li]:mb-1', - '[&_strong]:font-semibold [&_code]:text-xs [&_code]:bg-black/30 [&_code]:px-1 [&_code]:rounded', - '[&_table]:w-full [&_th]:text-left [&_th]:pr-3 [&_td]:pr-3 [&_a]:underline', -].join(' ') - -function Answer({ text }: { text: string }) { +function Answer({ text, compact }: { text: string; compact: boolean }) { return ( -
+
, + a: (props) => , + code: (props) => , + table: (props) => ( +
+ + + ), + th: (props) => ( + + ) + }, }} > {text} diff --git a/frontend/src/components/ChatWidget.tsx b/frontend/src/components/ChatWidget.tsx index 93a95c9..cc8d750 100644 --- a/frontend/src/components/ChatWidget.tsx +++ b/frontend/src/components/ChatWidget.tsx @@ -1,9 +1,7 @@ import { useState } from 'react' +import { ArrowUpRight, MessageSquare, X } from 'lucide-react' import ChatPanel from './ChatPanel' -const INSTRUCTIONS = - 'Ask about any player or metric in the database. I can rank, filter and compare.' - export default function ChatWidget() { const [open, setOpen] = useState(false) @@ -12,37 +10,24 @@ export default function ChatWidget() { ) } return ( -
-
-

Ask the data

-
- -

{INSTRUCTIONS}

- - - Open full screen ↗ - -
) diff --git a/frontend/src/components/FilterBar.tsx b/frontend/src/components/FilterBar.tsx index 739b439..980cc77 100644 --- a/frontend/src/components/FilterBar.tsx +++ b/frontend/src/components/FilterBar.tsx @@ -1,100 +1,66 @@ -import type { CompetitionList, FilterClause, FilterOp } from '../api/players' +import { useEffect, useRef, useState } from 'react' +import { Flag, Plus, Search, Shield } from 'lucide-react' +import type { CompetitionList, FilterClause, FilterOp, SortOrder } from '../api/players' +import { clauseLabel, metricLabel, type Filters } from '../lib/filters' import { FILTER_OP_OPTIONS, METRIC_OPTIONS } from '../api/players' +import { IconInput, Select } from './ui/Fields' +import FilterChip from './ui/FilterChip' +import Segmented from './ui/Segmented' -export interface Filters { - position: string; team: string; nationality: string; underpredicted_flag: string - stats_view: string; clauses: FilterClause[] -} +const VIEWS = [ + { value: 'all', label: 'All' }, + { value: 'club', label: 'Club' }, + { value: 'national', label: 'National' }, +] as const interface FilterBarProps { filters: Filters onChange: (filters: Filters) => void competitions?: CompetitionList + sortBy: string + order: SortOrder } -const POSITIONS = ['', 'GK', 'DF', 'MF', 'FW'] -const VIEW_SEGMENTS = [ - { value: '', label: 'All' }, - { value: 'club', label: 'Club' }, - { value: 'national', label: 'National' }, -] - -export default function FilterBar({ filters, onChange, competitions }: FilterBarProps) { - const set = (key: keyof Filters) => (e: React.ChangeEvent) => - onChange({ ...filters, [key]: e.target.value }) - - const setView = (value: string) => onChange({ ...filters, stats_view: value }) - - const clauses = filters.clauses - const setClauses = (next: FilterClause[]) => onChange({ ...filters, clauses: next }) - const addClause = () => - setClauses([...clauses, { field: METRIC_OPTIONS[0].value, op: 'gte', value: 0 }]) - const updateClause = (i: number, patch: Partial) => - setClauses(clauses.map((c, j) => (j === i ? { ...c, ...patch } : c))) - const removeClause = (i: number) => setClauses(clauses.filter((_, j) => j !== i)) - - const isCompView = filters.stats_view !== '' && filters.stats_view !== 'club' && filters.stats_view !== 'national' +export default function FilterBar({ filters, onChange, competitions, sortBy, order }: FilterBarProps) { + const set = (key: keyof Filters) => (v: string) => onChange({ ...filters, [key]: v }) + const isView = ['', 'club', 'national'].includes(filters.stats_view) + const removeClause = (i: number) => + onChange({ ...filters, clauses: filters.clauses.filter((_, j) => j !== i) }) return ( -
-
- - - - - - - + + {['GK', 'DF', 'MF', 'FW'].map((p) => )} + + + + + +
+ onChange({ ...filters, clauses: [...filters.clauses, c] })} /> +
- {/* Stats View selector */} -
- Stats view: - {VIEW_SEGMENTS.map(({ value, label }) => ( - - ))} - +
+ Stats view + set('stats_view')(v === 'all' ? '' : v)} + /> {competitions && ( - {competitions.club.length > 0 && ( @@ -107,50 +73,75 @@ export default function FilterBar({ filters, onChange, competitions }: FilterBar {competitions.national.map((c) => )} )} - + )} + {filters.clauses.length > 0 && } + {filters.clauses.map((c, i) => ( + removeClause(i)} /> + ))} + + sorted by {metricLabel(sortBy)} {order === 'asc' ? '▲' : '▼'} +
+
+ ) +} - {/* Metric filter builder */} -
- {clauses.map((c, i) => ( -
- {METRIC_OPTIONS.map((m) => )} - - + + updateClause(i, { value: e.target.valueAsNumber })} - className="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm w-24" + value={value} + onChange={(e) => setValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && add()} + placeholder="0" + className="field h-8 w-20 px-2 font-mono text-xs" /> -
- ))} - -
+ +
+ )} ) } diff --git a/frontend/src/components/PlayerCard.tsx b/frontend/src/components/PlayerCard.tsx index ce1d63d..a9184b1 100644 --- a/frontend/src/components/PlayerCard.tsx +++ b/frontend/src/components/PlayerCard.tsx @@ -1,235 +1,205 @@ import { useState } from 'react' -import { PieChart, Pie, Cell, Tooltip, Legend } from 'recharts' +import { ArrowLeftRight, ChevronDown, ChevronUp, X } from 'lucide-react' import type { Player, Stats } from '../api/players' +import { shortDate, totwCount } from '../lib/format' +import Avatar from './ui/Avatar' +import { Chip, CompTag, PosBadge, TotwBadge } from './ui/Badges' +import StackedBar from './ui/StackedBar' +import StatRow from './ui/StatRow' const ALL_STATS: { key: keyof Stats; label: string; decimals?: number }[] = [ { key: 'appearances', label: 'Appearances' }, - { key: 'matches_started', label: 'Matches Started' }, + { key: 'matches_started', label: 'Matches started' }, { key: 'minutes', label: 'Minutes' }, { key: 'goals', label: 'Goals' }, { key: 'assists', label: 'Assists' }, { key: 'xg', label: 'xG', decimals: 2 }, { key: 'xa', label: 'xA', decimals: 2 }, - { key: 'key_passes', label: 'Key Passes' }, - { key: 'big_chances_created', label: 'Big Chances Created' }, - { key: 'total_shots', label: 'Total Shots' }, - { key: 'shots_on_target', label: 'Shots On Target' }, - { key: 'shots_off_target', label: 'Shots Off Target' }, - { key: 'headed_goals', label: 'Headed Goals' }, - { key: 'right_foot_goals', label: 'Right Foot Goals' }, - { key: 'left_foot_goals', label: 'Left Foot Goals' }, - { key: 'scoring_frequency', label: 'Scoring Frequency', decimals: 2 }, - { key: 'pk_won', label: 'PK Won' }, - { key: 'pk_scored', label: 'PK Scored' }, - { key: 'pk_taken', label: 'PK Taken' }, - { key: 'pk_saved', label: 'PK Saved' }, - { key: 'penalty_miss', label: 'Penalty Miss' }, - { key: 'penalty_faced', label: 'Penalty Faced' }, - { key: 'penalty_conceded', label: 'Penalty Conceded' }, - { key: 'fouls_committed', label: 'Fouls Committed' }, - { key: 'yellow_cards', label: 'Yellow Cards' }, - { key: 'yellow_red_cards', label: '2nd Yellow (Red)' }, - { key: 'direct_red_cards', label: 'Direct Red' }, - { key: 'red_cards', label: 'Red Cards (total)' }, - { key: 'clean_sheets', label: 'Clean Sheets' }, + { key: 'key_passes', label: 'Key passes' }, + { key: 'big_chances_created', label: 'Big chances created' }, + { key: 'total_shots', label: 'Total shots' }, + { key: 'shots_on_target', label: 'Shots on target' }, + { key: 'shots_off_target', label: 'Shots off target' }, + { key: 'headed_goals', label: 'Headed goals' }, + { key: 'right_foot_goals', label: 'Right-foot goals' }, + { key: 'left_foot_goals', label: 'Left-foot goals' }, + { key: 'scoring_frequency', label: 'Scoring frequency', decimals: 2 }, + { key: 'pk_won', label: 'Penalties won' }, + { key: 'pk_scored', label: 'Penalties scored' }, + { key: 'pk_taken', label: 'Penalties taken' }, + { key: 'pk_saved', label: 'Penalties saved' }, + { key: 'penalty_miss', label: 'Penalties missed' }, + { key: 'penalty_faced', label: 'Penalties faced' }, + { key: 'penalty_conceded', label: 'Penalties conceded' }, + { key: 'fouls_committed', label: 'Fouls committed' }, + { key: 'yellow_cards', label: 'Yellow cards' }, + { key: 'yellow_red_cards', label: 'Second-yellow reds' }, + { key: 'direct_red_cards', label: 'Direct reds' }, + { key: 'red_cards', label: 'Red cards (total)' }, + { key: 'clean_sheets', label: 'Clean sheets' }, { key: 'saves', label: 'Saves' }, - { key: 'saves_outside_box', label: 'Saves Outside Box' }, - { key: 'goals_conceded', label: 'Goals Conceded' }, - { key: 'goals_prevented', label: 'Goals Prevented', decimals: 2 }, - { key: 'high_claims', label: 'High Claims' }, + { key: 'saves_outside_box', label: 'Saves outside box' }, + { key: 'goals_conceded', label: 'Goals conceded' }, + { key: 'goals_prevented', label: 'Goals prevented', decimals: 2 }, + { key: 'high_claims', label: 'High claims' }, { key: 'rating', label: 'Rating', decimals: 1 }, ] -interface PlayerCardProps { player: Player; bioLoading?: boolean } +const C = { green: '#34d98c', blue: '#6fa8ea', amber: '#e9b44c', red: '#e5564d', gray: '#2f3d36' } -const StatRow = ({ label, value }: { label: string; value: string | number }) => ( -
- {label} - {value} -
-) - -const SectionTitle = ({ children }: { children: React.ReactNode }) => ( -
{children}
-) - -interface DonutDatum { name: string; value: number; color: string } - -const Donut = ({ title, data }: { title: string; data: DonutDatum[] }) => { - const slices = data.filter((d) => d.value > 0) - return ( -
- {title} - - - {slices.map((d) => )} - - [`${v}`, name]} - contentStyle={{ background: '#1f2937', border: 'none', fontSize: 11 }} - /> - - -
- ) +interface PlayerCardProps { + player: Player + bioLoading?: boolean + onClose: () => void + onCompare?: () => void } -export default function PlayerCard({ player: p, bioLoading = false }: PlayerCardProps) { +export default function PlayerCard({ player: p, bioLoading = false, onClose, onCompare }: PlayerCardProps) { const s = p.aggregated_stats const sc = p.aggregated_scores const [showAll, setShowAll] = useState(false) - - const totwTotal = p.competitions.reduce( - (n, c) => n + (((c.raw_stats?.totwAppearances) as number) ?? 0), 0 - ) - - const showGoalTypes = s.goals > 0 && (s.headed_goals + s.left_foot_goals + s.right_foot_goals) > 0 - const showShots = s.total_shots > 0 + const totw = totwCount(p) + const blocked = Math.max(0, s.total_shots - s.shots_on_target - s.shots_off_target) + const pending = … return ( -
- {/* Header */} -
- {p.name} -
-
{p.name}
-
- {bioLoading && !p.position_exact ? … : p.position_exact} - {p.position_exact ? ' · ' : ''}{p.team} -
-
- {bioLoading && !p.nationality ? … : p.nationality} +
+
+ +
+

{p.name}

+
+ {bioLoading && !p.position_exact ? pending : } + {p.team} + · + {bioLoading && !p.nationality ? pending : p.nationality}
+
+ {totw > 0 && } + {sc.underpredicted_ratio != null && xRatio {sc.underpredicted_ratio.toFixed(2)}} + +
- {/* Score */} -
-
- Score -
{sc.s_final.toFixed(2)}
+
+
+
Fantasy Score
+
{sc.s_final.toFixed(2)}
-
- {sc.underpredicted_flag && ( - {sc.underpredicted_flag === 'HIGH_VALUE' ? 'Underpredicted' : 'Overperforming'} - )} - {totwTotal > 0 && ( - TOTW ×{totwTotal} - )} - {sc.underpredicted_ratio != null && ( - xRatio: {sc.underpredicted_ratio.toFixed(2)} - )} +
+ + + +
+
Confidence
+
+
+
+
+ {sc.confidence.toFixed(2)} +
- {/* Stats grid */} -
+
- Aggregate Stats - {s.appearances > 0 && ( - - )} +
Aggregate stats
+ + - - - - - - -
- -
- {p.position === 'GK' && s.saves > 0 && ( + {p.position === 'GK' ? ( <> - GK Stats - - - + + + + ) : ( + <> + + + )} + + +
- {p.competitions.length > 1 && ( -
0 ? 'mt-4' : ''}> - Per Competition - {p.competitions.map((c) => ( -
- - {c.competition} - - {c.competition_type === 'national' ? 'NT' : 'Club'} - - - {c.scores.s_final.toFixed(2)} -
- ))} +
+
Per competition
+ {p.competitions.map((c) => ( +
+ + {c.competition} + + + {c.scores.s_final.toFixed(2)} +
+ ))} + {s.goals > 0 && ( +
+ +
+ )} + {s.total_shots > 0 && ( +
+
)}
- {/* Charts row */} - {(showGoalTypes || showShots) && ( -
- {showGoalTypes && ( - - )} - {showShots && ( - - )} + {showAll && ( +
+ {ALL_STATS.map(({ key, label, decimals }) => { + const v = s[key] as number + return + })}
)} - {/* All data toggle */} -
- - {showAll && ( -
- {ALL_STATS.map(({ key, label, decimals }) => { - const val = s[key] as number - const formatted = decimals !== undefined ? val.toFixed(decimals) : val - return ( -
- {label} - {formatted} -
- ) - })} -
+ {onCompare && ( + )} + last updated {shortDate(p.last_updated, true)}
) } + +function ScoreStat({ label, value, strong = false }: { label: string; value: number; strong?: boolean }) { + return ( +
+
{label}
+
{value.toFixed(1)}
+
+ ) +} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 62f82a6..bdb5d2e 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { getPlayer, refreshBio, type Player } from '../api/players' +import { useApp } from '../context/AppContext' import PlayerCard from './PlayerCard' interface PlayerModalProps { @@ -9,8 +10,10 @@ interface PlayerModalProps { } export default function PlayerModal({ playerId, player: prefetched, onClose }: PlayerModalProps) { + const { season, go } = useApp() const [player, setPlayer] = useState(null) const [loading, setLoading] = useState(false) + const [failed, setFailed] = useState(false) const [bioLoading, setBioLoading] = useState(false) useEffect(() => { @@ -20,12 +23,13 @@ export default function PlayerModal({ playerId, player: prefetched, onClose }: P } if (!playerId) return setLoading(true) + setFailed(false) setPlayer(null) - getPlayer(playerId) + getPlayer(playerId, season || undefined) .then(setPlayer) - .catch(() => {}) + .catch(() => setFailed(true)) .finally(() => setLoading(false)) - }, [playerId, prefetched]) + }, [playerId, prefetched, season]) // Trigger lazy bio fetch when the modal opens with missing bio fields useEffect(() => { @@ -38,26 +42,28 @@ export default function PlayerModal({ playerId, player: prefetched, onClose }: P .finally(() => setBioLoading(false)) }, [player?.sofascore_player_id]) + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose() + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + const open = playerId != null || prefetched != null if (!open) return null + const compare = player?.sofascore_player_id + ? () => { onClose(); go('compare', { comparePlayerId: player.sofascore_player_id }) } + : undefined + return ( -
+
e.stopPropagation()} > - - {loading &&
Loading…
} - {player && } + {loading &&
Loading…
} + {failed &&
Could not load this player.
} + {player && }
) diff --git a/frontend/src/components/PlayerTable.tsx b/frontend/src/components/PlayerTable.tsx index a5ffa62..3c333e1 100644 --- a/frontend/src/components/PlayerTable.tsx +++ b/frontend/src/components/PlayerTable.tsx @@ -1,111 +1,72 @@ +import type { ReactNode } from 'react' import type { Player, SortOrder } from '../api/players' +export interface Column { + key: string + label: string + sortKey?: string + className?: string + render: (p: Player, rank: number) => ReactNode +} + interface PlayerTableProps { players: Player[] - total: number - page: number - pageSize: number - onPageChange: (page: number) => void - onPlayerClick: (player: Player) => void + columns: Column[] + rankOffset: number + onRowClick: (p: Player) => void sortBy?: string order?: SortOrder - onSortChange?: (field: string) => void + onSort?: (key: string) => void + dimmed?: boolean } -// Sortable metric columns in render order. `metric` is the backend field the API sorts on; -// `label` is the terse header text this table already displays (kept unchanged). -const SORT_COLUMNS: { metric: string; label: string }[] = [ - { metric: 's_final', label: 'S_final' }, - { metric: 'goals', label: 'G' }, - { metric: 'assists', label: 'A' }, - { metric: 'xg', label: 'xG' }, - { metric: 'xa', label: 'xA' }, - { metric: 'minutes', label: 'Min' }, -] - export default function PlayerTable({ - players, total, page, pageSize, onPageChange, onPlayerClick, sortBy, order, onSortChange, + players, columns, rankOffset, onRowClick, sortBy, order, onSort, dimmed = false, }: PlayerTableProps) { - const totalPages = Math.ceil(total / pageSize) - - const flagBadge = (flag: string | null) => { - if (!flag) return null - const color = flag === 'HIGH_VALUE' ? 'bg-amber-800 text-amber-200' : 'bg-green-800 text-green-200' - const label = flag === 'HIGH_VALUE' ? 'Underpredicted' : 'Overperforming' - return {label} - } - - const sortHeader = ({ metric, label }: { metric: string; label: string }) => { - if (!onSortChange) return
- const active = sortBy === metric - return ( - - ) - } - return ( -
-
-
+ ), + td: ({ children, ...props }) => { + const ratio = typeof children === 'string' && RATIO_CELL.test(children.trim()) + const numeric = typeof children === 'string' && /^[\d.,+-]+$/.test(children.trim()) + return ( + + {children} + {label} onSortChange(metric)} - className={`py-2 pr-4 cursor-pointer select-none hover:text-white ${active ? 'text-white' : ''}`} - > - {label}{active ? (order === 'desc' ? ' ▼' : ' ▲') : ''} -
- - - - - - - {SORT_COLUMNS.map(sortHeader)} - +
+
#PlayerPosTeamFlag
+ + + {columns.map((c, i) => { + const active = c.sortKey !== undefined && c.sortKey === sortBy + const sortable = c.sortKey !== undefined && onSort + return ( + + ) + })} + + + + {players.map((p, i) => ( + onRowClick(p)} + className="cursor-pointer transition-colors hover:bg-surface/70" + > + {columns.map((c, j) => ( + + ))} - - - {players.map((p, i) => ( - onPlayerClick(p)} - className="border-b border-gray-800 hover:bg-gray-800 cursor-pointer" - > - - - - - - - - - - - - - ))} - -
onSort(c.sortKey!) : undefined} + className={`h-11 bg-surface px-3 font-mono text-[10px] font-normal uppercase tracking-[0.16em] ${ + i === 0 ? 'rounded-tl-lg pl-4' : '' + } ${i === columns.length - 1 ? 'rounded-tr-lg' : ''} ${ + active ? 'text-accent' : 'text-dim' + } ${sortable ? 'cursor-pointer select-none hover:text-ink' : ''} ${c.className ?? ''}`} + > + {c.label} + {active && {order === 'asc' ? '▲' : '▼'}} +
+ {c.render(p, rankOffset + i + 1)} +
{(page - 1) * pageSize + i + 1}{p.name}{p.position}{p.team}{p.aggregated_scores.s_final.toFixed(2)}{p.aggregated_stats.goals}{p.aggregated_stats.assists}{p.aggregated_stats.xg.toFixed(1)}{p.aggregated_stats.xa.toFixed(1)}{p.aggregated_stats.minutes}{flagBadge(p.aggregated_scores.underpredicted_flag)}
-
- - {totalPages > 1 && ( -
- - {page} / {totalPages} - -
- )} + ))} + +
) } diff --git a/frontend/src/components/ScatterPlot.tsx b/frontend/src/components/ScatterPlot.tsx index cf262b7..4b5f859 100644 --- a/frontend/src/components/ScatterPlot.tsx +++ b/frontend/src/components/ScatterPlot.tsx @@ -1,22 +1,30 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { - ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, - ResponsiveContainer, Legend, ReferenceLine, + ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, } from 'recharts' -import type { ScatterPoint } from '../api/players' -interface ScatterPlotProps { - data: ScatterPoint[] - onPointClick?: (point: ScatterPoint) => void +export interface PlotPoint { + id: string + name: string + x: number + y: number + color: string + radius: number + label?: string + selected?: boolean } -const POSITION_COLORS: Record = { - GK: '#6366f1', DF: '#22c55e', MF: '#f59e0b', FW: '#ef4444', +interface ScatterPlotProps { + points: PlotPoint[] + xLabel: string + yLabel: string + onPointClick?: (id: string) => void } -const Y_AXIS_WIDTH = 55 -const MARGIN = { top: 24, right: 24, bottom: 44, left: 8 } +const Y_AXIS_WIDTH = 44 +const MARGIN = { top: 24, right: 28, bottom: 40, left: 16 } const MIN_ZOOM_FRACTION = 0.03 // don't let a single wheel-zoom session collapse past 3% of the full domain +const TICK = { fill: '#56635c', fontSize: 11, fontFamily: 'JetBrains Mono' } type Domain = [number, number] @@ -25,8 +33,7 @@ function clampWidth(width: number, full: Domain): number { return Math.min(fullWidth, Math.max(fullWidth * MIN_ZOOM_FRACTION, width)) } -// Shift a domain window back inside the full data range, preserving its width — -// used to stop panning past the edges of the actual data. +// Shift a domain window back inside the full data range, preserving its width. function clampDomain(domain: Domain, full: Domain): Domain { const width = domain[1] - domain[0] let [lo, hi] = domain @@ -35,58 +42,67 @@ function clampDomain(domain: Domain, full: Domain): Domain { return [lo, hi] } -export default function ScatterPlot({ data, onPointClick }: ScatterPlotProps) { +interface ShapeProps { cx?: number; cy?: number; payload?: PlotPoint } + +function PointShape({ cx = 0, cy = 0, payload }: ShapeProps) { + if (!payload) return null + return ( + + {payload.selected && } + + {payload.label && ( + + {payload.label} + + )} + + ) +} + +export default function ScatterPlot({ points, xLabel, yLabel, onPointClick }: ScatterPlotProps) { const containerRef = useRef(null) const [xDomain, setXDomain] = useState(null) const [yDomain, setYDomain] = useState(null) - const byPosition = useMemo(() => { - const grouped: Record = {} - for (const p of data) { - const pos = p.position || 'MF' - grouped[pos] = grouped[pos] ?? [] - grouped[pos].push(p) - } - return grouped - }, [data]) - - const fullXDomain = useMemo(() => { - const maxXgXa = data.reduce((m, p) => Math.max(m, p.xg_xa), 0) - return [0, Math.max(Math.round(maxXgXa * 1.05 * 100) / 100, 1)] - }, [data]) - - const fullYDomain = useMemo(() => { - const maxGA = data.reduce((m, p) => Math.max(m, p.g_a), 0) - return [0, Math.max(Math.round(maxGA * 1.05 * 100) / 100, 1)] - }, [data]) - - const refMax = Math.max(fullXDomain[1], fullYDomain[1]) - - // Reset zoom whenever the underlying dataset changes (e.g. season switch). + // One shared range for both axes, rounded up to a whole number of ~6 even steps. + const { full, ticks } = useMemo(() => { + const max = Math.max(points.reduce((m, p) => Math.max(m, p.x, p.y), 0) * 1.03, 1) + const raw = max / 6 + const mag = 10 ** Math.floor(Math.log10(raw)) + const step = ([1, 2, 2.5, 5, 10].find((f) => f * mag >= raw) ?? 10) * mag + const top = Math.ceil(max / step) * step + return { full: [0, top] as Domain, ticks: Array.from({ length: Math.round(top / step) + 1 }, (_, i) => i * step) } + }, [points]) + + // Reset zoom whenever the axes or the range change, not on every point restyle. + const top = full[1] useEffect(() => { setXDomain(null) setYDomain(null) - }, [data]) + }, [top, xLabel, yLabel]) - // Pending domain lives in refs, updated synchronously on every wheel tick (cheap). - // The expensive part — flushing to React state, which re-renders ~1200 points — is - // throttled to one commit per animation frame so a fast trackpad fling doesn't queue - // a render per wheel event. - const pendingXRef = useRef(fullXDomain) - const pendingYRef = useRef(fullYDomain) - const fullXDomainRef = useRef(fullXDomain) - const fullYDomainRef = useRef(fullYDomain) + // Wheel ticks update refs; React state is flushed at most once per frame. + const pendingXRef = useRef(full) + const pendingYRef = useRef(full) + const fullRef = useRef(full) const rafRef = useRef(null) - useEffect(() => { fullXDomainRef.current = fullXDomain }, [fullXDomain]) - useEffect(() => { fullYDomainRef.current = fullYDomain }, [fullYDomain]) + useEffect(() => { fullRef.current = full }, [full]) useEffect(() => { - pendingXRef.current = xDomain ?? fullXDomain - pendingYRef.current = yDomain ?? fullYDomain - }, [xDomain, yDomain, fullXDomain, fullYDomain]) + pendingXRef.current = xDomain ?? full + pendingYRef.current = yDomain ?? full + }, [xDomain, yDomain, full]) useEffect(() => { const el = containerRef.current if (!el) return + const flush = () => { + if (rafRef.current !== null) return + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null + setXDomain(pendingXRef.current) + setYDomain(pendingYRef.current) + }) + } const onWheel = (e: WheelEvent) => { e.preventDefault() @@ -99,185 +115,122 @@ export default function ScatterPlot({ data, onPointClick }: ScatterPlotProps) { const plotBottom = rect.bottom - MARGIN.bottom const fracX = Math.min(1, Math.max(0, (e.clientX - plotLeft) / (plotRight - plotLeft))) const fracY = Math.min(1, Math.max(0, 1 - (e.clientY - plotTop) / (plotBottom - plotTop))) - const dataX = curX[0] + fracX * (curX[1] - curX[0]) const dataY = curY[0] + fracY * (curY[1] - curY[0]) - const zoomFactor = e.deltaY < 0 ? 0.85 : 1 / 0.85 - - const newWidthX = clampWidth((curX[1] - curX[0]) * zoomFactor, fullXDomainRef.current) - const newWidthY = clampWidth((curY[1] - curY[0]) * zoomFactor, fullYDomainRef.current) - - pendingXRef.current = [dataX - fracX * newWidthX, dataX + (1 - fracX) * newWidthX] - pendingYRef.current = [dataY - fracY * newWidthY, dataY + (1 - fracY) * newWidthY] - - if (rafRef.current === null) { - rafRef.current = requestAnimationFrame(() => { - rafRef.current = null - setXDomain(pendingXRef.current) - setYDomain(pendingYRef.current) - }) - } + const zoom = e.deltaY < 0 ? 0.85 : 1 / 0.85 + const wX = clampWidth((curX[1] - curX[0]) * zoom, fullRef.current) + const wY = clampWidth((curY[1] - curY[0]) * zoom, fullRef.current) + pendingXRef.current = [dataX - fracX * wX, dataX + (1 - fracX) * wX] + pendingYRef.current = [dataY - fracY * wY, dataY + (1 - fracY) * wY] + flush() } - el.addEventListener('wheel', onWheel, { passive: false }) - return () => { - el.removeEventListener('wheel', onWheel) - if (rafRef.current !== null) cancelAnimationFrame(rafRef.current) - } - }, []) - - // Click-and-drag panning, for navigating around once zoomed in. Uses the same - // pending-ref + rAF-flush pattern as the wheel handler to stay smooth. - useEffect(() => { - const el = containerRef.current - if (!el) return - - let dragStart: { - x: number; y: number; domainX: Domain; domainY: Domain; plotWidth: number; plotHeight: number - } | null = null + let drag: { x: number; y: number; dX: Domain; dY: Domain; w: number; h: number } | null = null let dragged = false - const onMouseMove = (e: MouseEvent) => { - if (!dragStart) return - const dx = e.clientX - dragStart.x - const dy = e.clientY - dragStart.y + const onMove = (e: MouseEvent) => { + if (!drag) return + const dx = e.clientX - drag.x + const dy = e.clientY - drag.y if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragged = true - - const deltaDataX = (dx / dragStart.plotWidth) * (dragStart.domainX[1] - dragStart.domainX[0]) - const deltaDataY = (dy / dragStart.plotHeight) * (dragStart.domainY[1] - dragStart.domainY[0]) - - pendingXRef.current = clampDomain( - [dragStart.domainX[0] - deltaDataX, dragStart.domainX[1] - deltaDataX], - fullXDomainRef.current, - ) - pendingYRef.current = clampDomain( - [dragStart.domainY[0] + deltaDataY, dragStart.domainY[1] + deltaDataY], - fullYDomainRef.current, - ) - - if (rafRef.current === null) { - rafRef.current = requestAnimationFrame(() => { - rafRef.current = null - setXDomain(pendingXRef.current) - setYDomain(pendingYRef.current) - }) - } + const shiftX = (dx / drag.w) * (drag.dX[1] - drag.dX[0]) + const shiftY = (dy / drag.h) * (drag.dY[1] - drag.dY[0]) + pendingXRef.current = clampDomain([drag.dX[0] - shiftX, drag.dX[1] - shiftX], fullRef.current) + pendingYRef.current = clampDomain([drag.dY[0] + shiftY, drag.dY[1] + shiftY], fullRef.current) + flush() } - - const onMouseUp = () => { - dragStart = null + const onUp = () => { + drag = null el.style.cursor = 'grab' - window.removeEventListener('mousemove', onMouseMove) - window.removeEventListener('mouseup', onMouseUp) + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) } - - const onMouseDown = (e: MouseEvent) => { + const onDown = (e: MouseEvent) => { if (e.button !== 0) return - e.preventDefault() // avoid native text-selection while dragging + e.preventDefault() dragged = false const rect = el.getBoundingClientRect() - dragStart = { - x: e.clientX, - y: e.clientY, - domainX: pendingXRef.current, - domainY: pendingYRef.current, - plotWidth: rect.width - MARGIN.left - Y_AXIS_WIDTH - MARGIN.right, - plotHeight: rect.height - MARGIN.top - MARGIN.bottom, + drag = { + x: e.clientX, y: e.clientY, dX: pendingXRef.current, dY: pendingYRef.current, + w: rect.width - MARGIN.left - Y_AXIS_WIDTH - MARGIN.right, + h: rect.height - MARGIN.top - MARGIN.bottom, } el.style.cursor = 'grabbing' - window.addEventListener('mousemove', onMouseMove) - window.addEventListener('mouseup', onMouseUp) + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) } - - // Swallow the click that follows a drag so it doesn't also open a dot's player modal. + // Swallow the click that ends a drag so it does not also select a point. const onClickCapture = (e: MouseEvent) => { - if (dragged) { - e.stopPropagation() - dragged = false - } + if (dragged) { e.stopPropagation(); dragged = false } } - el.addEventListener('mousedown', onMouseDown) + el.addEventListener('wheel', onWheel, { passive: false }) + el.addEventListener('mousedown', onDown) el.addEventListener('click', onClickCapture, true) return () => { - el.removeEventListener('mousedown', onMouseDown) + el.removeEventListener('wheel', onWheel) + el.removeEventListener('mousedown', onDown) el.removeEventListener('click', onClickCapture, true) - window.removeEventListener('mousemove', onMouseMove) - window.removeEventListener('mouseup', onMouseUp) + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current) } }, []) const isZoomed = xDomain !== null || yDomain !== null - const resetZoom = () => { setXDomain(null); setYDomain(null) } + const fmt = (v: number) => String(Number(v.toFixed(1))) return ( -
-
- {isZoomed && ( - - )} - - - - String(Number(v.toFixed(2)))} - label={{ value: 'xG + xA', position: 'insideBottom', offset: -20, fill: '#9ca3af' }} - stroke="#6b7280" - /> - String(Number(v.toFixed(2)))} - label={{ value: 'Goals + Assists', angle: -90, position: 'insideLeft', offset: 10, fill: '#9ca3af' }} - stroke="#6b7280" - /> - {/* diagonal y=x: above = underperformer (potential sleeper) */} - - { - if (!payload?.length) return null - const p = payload[0].payload as ScatterPoint - return ( -
-
{p.name}
-
{p.position}
-
G+A: {p.g_a} · xG+xA: {p.xg_xa.toFixed(2)}
-
- ) - }} - /> - - {Object.entries(byPosition).map(([pos, points]) => ( - onPointClick(point as unknown as ScatterPoint) : undefined} - /> - ))} -
-
-
-
Scroll to zoom · drag to pan
+
+ {isZoomed && ( + + )} + + + + + + + { + if (!payload?.length) return null + const p = payload[0].payload as PlotPoint + return ( +
+
{p.name}
+
{xLabel} {p.x.toFixed(1)} · {yLabel} {p.y}
+
+ ) + }} + /> + } + isAnimationActive={false} + cursor={onPointClick ? 'pointer' : undefined} + onClick={onPointClick ? (p) => onPointClick((p as unknown as PlotPoint).id) : undefined} + /> +
+
) } diff --git a/frontend/src/components/SeedPrompt.tsx b/frontend/src/components/SeedPrompt.tsx deleted file mode 100644 index 5530b8a..0000000 --- a/frontend/src/components/SeedPrompt.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { ReactNode } from 'react' - -export default function SeedPrompt() { - return ( -
-
⚽
-

No player data loaded

-

- Data loading is now developer-driven via the fetch_cli tool. - Run it from tools/fetch_cli (see its README), then refresh - this page. -

-
- ) -} - -function Center({ children }: { children: ReactNode }) { - return ( -
- {children} -
- ) -} diff --git a/frontend/src/components/playerColumns.tsx b/frontend/src/components/playerColumns.tsx new file mode 100644 index 0000000..05489ef --- /dev/null +++ b/frontend/src/components/playerColumns.tsx @@ -0,0 +1,47 @@ +import { totwCount } from '../lib/format' +import Avatar from './ui/Avatar' +import { FlagBadge, PosBadge, TotwBadge } from './ui/Badges' +import type { Column } from './PlayerTable' + +const num = 'font-mono text-ink/90' + +export const COL = { + rank: { key: 'rank', label: '#', className: 'w-10 font-mono text-dim', render: (_p, r) => r }, + player: { + key: 'player', + label: 'Player', + render: (p) => ( +
+ +
+
{p.name}
+
{p.nationality || '—'}
+
+
+ ), + }, + pos: { key: 'pos', label: 'Pos', render: (p) => }, + team: { key: 'team', label: 'Team', className: 'text-ink/90', render: (p) => p.team }, + apps: { key: 'apps', label: 'Apps', sortKey: 'appearances', className: num, render: (p) => p.aggregated_stats.appearances }, + goals: { key: 'g', label: 'G', sortKey: 'goals', className: 'font-mono text-ink', render: (p) => p.aggregated_stats.goals }, + assists: { key: 'a', label: 'A', sortKey: 'assists', className: 'font-mono text-ink', render: (p) => p.aggregated_stats.assists }, + xg: { key: 'xg', label: 'xG', sortKey: 'xg', className: num, render: (p) => p.aggregated_stats.xg.toFixed(1) }, + xa: { key: 'xa', label: 'xA', sortKey: 'xa', className: num, render: (p) => p.aggregated_stats.xa.toFixed(1) }, + minutes: { key: 'min', label: 'Min', sortKey: 'minutes', className: num, render: (p) => p.aggregated_stats.minutes }, + rating: { key: 'rating', label: 'Rating', sortKey: 'rating', className: num, render: (p) => p.aggregated_stats.rating.toFixed(1) }, + signal: { + key: 'signal', + label: 'Signal', + render: (p) => { + const flag = p.aggregated_scores.underpredicted_flag + const totw = totwCount(p) + if (!flag && !totw) return — + return ( +
+ {flag && } + {totw > 0 && } +
+ ) + }, + }, +} satisfies Record diff --git a/frontend/src/components/states/SystemStates.tsx b/frontend/src/components/states/SystemStates.tsx new file mode 100644 index 0000000..4726e23 --- /dev/null +++ b/frontend/src/components/states/SystemStates.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react' +import { ArrowUpRight, Copy, Database, RefreshCw, SearchX, WifiOff } from 'lucide-react' +import { API_DOCS_URL } from '../../api/meta' + +const CLI_STEPS = [ + { cmd: 'python -m fetch_cli.cli refresh', note: 'pull the competition / season catalog' }, + { cmd: 'python -m fetch_cli.cli browse', note: 'see what is available' }, + { cmd: 'python -m fetch_cli.cli fetch', note: 'pick a league + season, load it' }, +] + +export function EmptyDatabase({ onRetry }: { onRetry: () => void }) { + const [copied, setCopied] = useState(false) + const copy = () => { + navigator.clipboard?.writeText(CLI_STEPS.map((s) => s.cmd).join('\n')).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }).catch(() => {}) + } + return ( +
+
+ +
+

No data loaded yet

+

+ The database is empty. Loading is developer-driven — run the fetch CLI against the running + backend to pull a competition and season into MongoDB. +

+
+
+ tools/ + +
+
+ {CLI_STEPS.map((s) => ( +
+ ${s.cmd} + # {s.note} +
+ ))} +
+
+
+ + Open API docs + + +
+
+ ) +} + +export function BackendUnreachable({ onRetry }: { onRetry: () => void }) { + return ( +
+ +

Backend unreachable

+

+ Cannot reach the API. Make sure the backend is running. +

+

retrying every 10s

+ +
+ ) +} + +export function NoResults({ + active, onClearAll, onRemoveLast, +}: { active: string[]; onClearAll: () => void; onRemoveLast?: () => void }) { + return ( +
+ +

No results

+

No players match this search.

+ {active.length > 0 && ( +
+ {active.map((a) => ( + {a} + ))} +
+ )} +
+ + {onRemoveLast && ( + + )} +
+
+ ) +} + +export function TableSkeleton({ rows = 10 }: { rows?: number }) { + return ( +
+
+ {Array.from({ length: rows }, (_, i) => ( +
+
+
+
+
+
+ ))} +
+ ) +} diff --git a/frontend/src/components/ui/Avatar.tsx b/frontend/src/components/ui/Avatar.tsx new file mode 100644 index 0000000..8ed36fc --- /dev/null +++ b/frontend/src/components/ui/Avatar.tsx @@ -0,0 +1,13 @@ +import { initials } from '../../lib/format' + +const SIZES = { sm: 'h-7 w-7 text-[9px]', md: 'h-12 w-12 text-sm', lg: 'h-16 w-16 text-lg' } + +export default function Avatar({ name, size = 'sm' }: { name: string; size?: keyof typeof SIZES }) { + return ( + + {initials(name)} + + ) +} diff --git a/frontend/src/components/ui/Badges.tsx b/frontend/src/components/ui/Badges.tsx new file mode 100644 index 0000000..cf9068d --- /dev/null +++ b/frontend/src/components/ui/Badges.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react' +import { Star } from 'lucide-react' +import type { Flag } from '../../api/players' + +const FLAG_LABEL: Record = { + HIGH_VALUE: 'Due to score', + OVERPERFORMING: 'Overperforming', +} + +const POS_TONE: Record = { GK: 'text-warn', DF: 'text-info', MF: 'text-muted' } + +export function PosBadge({ pos }: { pos: string }) { + return ( + + {pos || '—'} + + ) +} + +export function FlagBadge({ flag }: { flag: Flag }) { + const tone = flag === 'HIGH_VALUE' ? 'bg-warn-soft text-warn' : 'bg-accent-soft text-accent' + return ( + + {FLAG_LABEL[flag]} + + ) +} + +export function TotwBadge({ count }: { count: number }) { + return ( + + TOTW ×{count} + + ) +} + +export function Chip({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export function CompTag({ national }: { national: boolean }) { + return ( + + {national ? 'NT' : 'CLUB'} + + ) +} diff --git a/frontend/src/components/ui/Fields.tsx b/frontend/src/components/ui/Fields.tsx new file mode 100644 index 0000000..2abf25a --- /dev/null +++ b/frontend/src/components/ui/Fields.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from 'react' +import { ChevronDown, type LucideIcon } from 'lucide-react' + +export function IconInput({ + icon: Icon, value, onChange, placeholder, className = '', +}: { + icon: LucideIcon; value: string; onChange: (v: string) => void + placeholder: string; className?: string +}) { + return ( + + ) +} + +export function Select({ + value, onChange, children, className = '', prefix, size = 'md', +}: { + value: string; onChange: (v: string) => void; children: ReactNode + className?: string; prefix?: string; size?: 'sm' | 'md' +}) { + return ( + + ) +} diff --git a/frontend/src/components/ui/FilterChip.tsx b/frontend/src/components/ui/FilterChip.tsx new file mode 100644 index 0000000..d0c4d1f --- /dev/null +++ b/frontend/src/components/ui/FilterChip.tsx @@ -0,0 +1,12 @@ +import { X } from 'lucide-react' + +export default function FilterChip({ label, onRemove }: { label: string; onRemove: () => void }) { + return ( + + {label} + + + ) +} diff --git a/frontend/src/components/ui/PageHeader.tsx b/frontend/src/components/ui/PageHeader.tsx new file mode 100644 index 0000000..a43702b --- /dev/null +++ b/frontend/src/components/ui/PageHeader.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react' + +export default function PageHeader({ + title, subtitle, right, +}: { title: string; subtitle?: ReactNode; right?: ReactNode }) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {right &&
{right}
} +
+ ) +} + +export const Dot = () => · diff --git a/frontend/src/components/ui/Pagination.tsx b/frontend/src/components/ui/Pagination.tsx new file mode 100644 index 0000000..c034db6 --- /dev/null +++ b/frontend/src/components/ui/Pagination.tsx @@ -0,0 +1,31 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react' + +export default function Pagination({ + page, pageSize, total, shown, onPage, +}: { page: number; pageSize: number; total: number; shown: number; onPage: (p: number) => void }) { + const pages = Math.max(1, Math.ceil(total / pageSize)) + const from = total === 0 ? 0 : (page - 1) * pageSize + 1 + const to = Math.max(from, from + shown - 1) + return ( +
+ + Showing {from}–{to} of {total.toLocaleString('en-US')} + +
+ + + {page} / {pages} + + +
+
+ ) +} diff --git a/frontend/src/components/ui/ScoreBar.tsx b/frontend/src/components/ui/ScoreBar.tsx new file mode 100644 index 0000000..dd599f4 --- /dev/null +++ b/frontend/src/components/ui/ScoreBar.tsx @@ -0,0 +1,16 @@ +// Value above a thin progress track, as in the ranking tables. +export default function ScoreBar({ + label, fraction, tone = 'accent', +}: { label: string; fraction: number; tone?: 'accent' | 'warn' }) { + const text = tone === 'warn' ? 'text-warn' : 'text-accent' + const fill = tone === 'warn' ? 'bg-warn' : 'bg-accent' + const pct = Math.max(0, Math.min(1, fraction)) * 100 + return ( +
+
{label}
+
+
+
+
+ ) +} diff --git a/frontend/src/components/ui/Segmented.tsx b/frontend/src/components/ui/Segmented.tsx new file mode 100644 index 0000000..0d5316b --- /dev/null +++ b/frontend/src/components/ui/Segmented.tsx @@ -0,0 +1,25 @@ +interface Option { value: T; label: string } + +export default function Segmented({ + options, value, onChange, size = 'md', mono = false, +}: { + options: Option[]; value: T | null; onChange: (v: T) => void + size?: 'sm' | 'md'; mono?: boolean +}) { + const h = size === 'sm' ? 'h-8 text-xs' : 'h-9 text-sm' + return ( +
+ {options.map((o) => ( + + ))} +
+ ) +} diff --git a/frontend/src/components/ui/StackedBar.tsx b/frontend/src/components/ui/StackedBar.tsx new file mode 100644 index 0000000..5e22025 --- /dev/null +++ b/frontend/src/components/ui/StackedBar.tsx @@ -0,0 +1,27 @@ +interface Segment { label: string; value: number; color: string } + +export default function StackedBar({ + title, total, segments, +}: { title: string; total: number; segments: Segment[] }) { + const sum = segments.reduce((n, s) => n + s.value, 0) || 1 + return ( +
+
+ {title} · {total} total +
+
+ {segments.filter((s) => s.value > 0).map((s) => ( +
+ ))} +
+
+ {segments.map((s) => ( + + + {s.label} {s.value} + + ))} +
+
+ ) +} diff --git a/frontend/src/components/ui/StatRow.tsx b/frontend/src/components/ui/StatRow.tsx new file mode 100644 index 0000000..12d6e64 --- /dev/null +++ b/frontend/src/components/ui/StatRow.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react' + +export default function StatRow({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label} + {value} +
+ ) +} diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx new file mode 100644 index 0000000..eab6417 --- /dev/null +++ b/frontend/src/context/AppContext.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext } from 'react' +import type { Meta } from '../api/meta' + +export type Tab = 'players' | 'compare' | 'sleepers' | 'scatter' | 'chat' + +export interface GoOptions { comparePlayerId?: string } + +export interface AppState { + season: string + meta: Meta | null + go: (tab: Tab, opts?: GoOptions) => void +} + +export const AppContext = createContext({ season: '', meta: null, go: () => {} }) + +export const useApp = () => useContext(AppContext) diff --git a/frontend/src/index.css b/frontend/src/index.css index b5c61c9..9e6983c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,18 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@layer base { + html { color-scheme: dark; } + body { @apply bg-bg text-ink font-sans antialiased; } + .font-mono { font-variant-numeric: tabular-nums; } +} + +@layer components { + .label-caps { @apply font-mono text-[10px] uppercase tracking-[0.18em] text-muted; } + .field { @apply h-10 rounded-md border border-line-strong bg-surface text-sm text-ink placeholder:text-dim outline-none transition-colors focus:border-accent-deep; } + .btn { @apply inline-flex items-center gap-1.5 rounded-md border px-3 h-9 text-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed; } + .btn-ghost { @apply btn border-line-strong bg-surface text-ink hover:bg-surface-2; } + .btn-accent { @apply btn border-accent-deep/60 bg-accent-soft text-accent hover:bg-accent-deep/30; } + .btn-solid { @apply btn border-transparent bg-accent text-bg font-semibold hover:brightness-110; } +} diff --git a/frontend/src/lib/filters.ts b/frontend/src/lib/filters.ts new file mode 100644 index 0000000..3cf66de --- /dev/null +++ b/frontend/src/lib/filters.ts @@ -0,0 +1,17 @@ +import type { FilterClause } from '../api/players' +import { FILTER_OP_OPTIONS, METRIC_OPTIONS } from '../api/players' + +export interface Filters { + name: string; position: string; team: string; nationality: string; underpredicted_flag: string + stats_view: string; clauses: FilterClause[] +} + +export const EMPTY_FILTERS: Filters = { + name: '', position: '', team: '', nationality: '', underpredicted_flag: '', stats_view: '', clauses: [], +} + +export const metricLabel = (field: string) => + METRIC_OPTIONS.find((m) => m.value === field)?.label ?? field + +export const clauseLabel = (c: FilterClause) => + `${metricLabel(c.field)} ${FILTER_OP_OPTIONS.find((o) => o.value === c.op)?.label ?? c.op} ${c.value}` diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..2932c1a --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,35 @@ +import type { Player } from '../api/players' + +export const initials = (name: string) => + name + .replace(/^[A-Z]\.\s*/, '') + .split(/\s+/) + .filter(Boolean) + .map((w) => w[0]) + .join('') + .slice(0, 2) + .toUpperCase() || '?' + +export const int = (n: number) => Math.round(n).toLocaleString('en-US') + +export const signed = (n: number, d = 1) => `${n > 0 ? '+' : ''}${n.toFixed(d)}` + +// Sofascore reports TOTW per competition, in untyped raw_stats. +export const totwCount = (p: Player) => + p.competitions.reduce((n, c) => n + (Number(c.raw_stats?.totwAppearances) || 0), 0) + +export const xgiGap = (p: Player) => { + const s = p.aggregated_stats + return s.xg + s.xa - (s.goals + s.assists) +} + +export const shortDate = (iso: string | null | undefined, withYear = false) => { + if (!iso) return '' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + return d.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + ...(withYear ? { year: 'numeric' } : {}), + }) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4a1b150..f5b37c5 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,10 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import '@fontsource/inter/400.css' +import '@fontsource/inter/500.css' +import '@fontsource/inter/600.css' +import '@fontsource/jetbrains-mono/400.css' +import '@fontsource/jetbrains-mono/500.css' import App from './App' import './index.css' diff --git a/frontend/src/pages/ChatFullScreen.tsx b/frontend/src/pages/ChatFullScreen.tsx index 1800e28..80b4028 100644 --- a/frontend/src/pages/ChatFullScreen.tsx +++ b/frontend/src/pages/ChatFullScreen.tsx @@ -1,20 +1,21 @@ +import { ArrowLeft } from 'lucide-react' import ChatPanel from '../components/ChatPanel' +import { AppContext } from '../context/AppContext' + +// Standalone page: "Open in Player details" leaves for the dashboard. +const ctx = { season: '', meta: null, go: () => { window.location.href = '/' } } export default function ChatFullScreen() { return ( -
-
-
-

Ask the data

- - ← Back to rankings + +
+ -

- Ask about any player or metric in the database. I can rank, filter and compare. -

-
-
+ ) } diff --git a/frontend/src/pages/Compare.tsx b/frontend/src/pages/Compare.tsx index 4391654..ff6dc6a 100644 --- a/frontend/src/pages/Compare.tsx +++ b/frontend/src/pages/Compare.tsx @@ -1,92 +1,188 @@ -import { useState, useEffect, useRef } from 'react' -import { getPlayers, type Player, type Stats } from '../api/players' +import { useEffect, useRef, useState } from 'react' +import { ArrowLeftRight, Search } from 'lucide-react' +import { getPlayer, getPlayers, type Player } from '../api/players' +import { useApp } from '../context/AppContext' +import Avatar from '../components/ui/Avatar' +import { PosBadge } from '../components/ui/Badges' +import PageHeader, { Dot } from '../components/ui/PageHeader' -const STAT_LABELS: { key: keyof Stats; label: string; higherIsBetter: boolean }[] = [ - { key: 'goals', label: 'Goals', higherIsBetter: true }, - { key: 'assists', label: 'Assists', higherIsBetter: true }, - { key: 'xg', label: 'xG', higherIsBetter: true }, - { key: 'xa', label: 'xA', higherIsBetter: true }, - { key: 'minutes', label: 'Minutes', higherIsBetter: true }, - { key: 'key_passes', label: 'Key Passes', higherIsBetter: true }, - { key: 'big_chances_created', label: 'Big Chances', higherIsBetter: true }, - { key: 'clean_sheets', label: 'Clean Sheets', higherIsBetter: true }, - { key: 'pk_won', label: 'PK Won', higherIsBetter: true }, - { key: 'rating', label: 'Rating', higherIsBetter: true }, - { key: 'yellow_cards', label: 'Yellow Cards', higherIsBetter: false }, - { key: 'red_cards', label: 'Red Cards', higherIsBetter: false }, +interface Metric { label: string; get: (p: Player) => number; decimals: number; lowerIsBetter?: boolean } + +const METRICS: Metric[] = [ + { label: 'Fantasy Score', get: (p) => p.aggregated_scores.s_final, decimals: 2 }, + { label: 'Offensive', get: (p) => p.aggregated_scores.offensive, decimals: 1 }, + { label: 'Defensive', get: (p) => p.aggregated_scores.defensive, decimals: 1 }, + { label: 'Tactical', get: (p) => p.aggregated_scores.tactical, decimals: 1 }, + { label: 'Goals', get: (p) => p.aggregated_stats.goals, decimals: 0 }, + { label: 'Assists', get: (p) => p.aggregated_stats.assists, decimals: 0 }, + { label: 'xG', get: (p) => p.aggregated_stats.xg, decimals: 1 }, + { label: 'xA', get: (p) => p.aggregated_stats.xa, decimals: 1 }, + { label: 'Minutes', get: (p) => p.aggregated_stats.minutes, decimals: 0 }, + { label: 'Appearances', get: (p) => p.aggregated_stats.appearances, decimals: 0 }, + { label: 'Key passes', get: (p) => p.aggregated_stats.key_passes, decimals: 0 }, + { label: 'Big chances created', get: (p) => p.aggregated_stats.big_chances_created, decimals: 0 }, + { label: 'Penalties won', get: (p) => p.aggregated_stats.pk_won, decimals: 0 }, + { label: 'Rating', get: (p) => p.aggregated_stats.rating, decimals: 1 }, + { label: 'Yellow cards', get: (p) => p.aggregated_stats.yellow_cards, decimals: 0, lowerIsBetter: true }, + { label: 'Red cards', get: (p) => p.aggregated_stats.red_cards, decimals: 0, lowerIsBetter: true }, ] -function fmt(v: number): string { - return Number.isInteger(v) ? String(v) : v.toFixed(2) +const LOSER_A = '#1c4a33' +const LOSER_B = '#2b3c4d' + +export default function Compare({ seedId, onSeedUsed }: { seedId: string | null; onSeedUsed: () => void }) { + const { season } = useApp() + const [a, setA] = useState(null) + const [b, setB] = useState(null) + + useEffect(() => { + if (!seedId) return + getPlayer(seedId, season).then(setA).catch(console.error).finally(onSeedUsed) + }, [seedId, season, onSeedUsed]) + + return ( +
+ Head-to-head across every scoring and stat dimensionΔ is A − B} + /> + +
+ + VS + +
+ + {a && b ? ( +
+
+ AMetric + BΔ +
+ {METRICS.map((m) => )} +
+ ) : ( +

Pick two players to see them head-to-head.

+ )} +
+ ) +} + +function CompareRow({ m, a, b }: { m: Metric; a: Player; b: Player }) { + const va = m.get(a) + const vb = m.get(b) + const max = Math.max(Math.abs(va), Math.abs(vb)) || 1 + const aWins = m.lowerIsBetter ? va < vb : va > vb + const bWins = m.lowerIsBetter ? vb < va : vb > va + const diff = va - vb + const fmt = (v: number) => v.toFixed(m.decimals) + const good = m.lowerIsBetter ? diff < 0 : diff > 0 + + return ( +
+ {fmt(va)} +
+
+
+ {m.label} +
+
+
+ {fmt(vb)} + + {diff === 0 ? '—' : `${diff > 0 ? '+' : ''}${fmt(diff)}`} + +
+ ) } -function DiffCell({ a, b, higherIsBetter }: { a: number; b: number; higherIsBetter: boolean }) { - const diff = a - b - if (diff === 0) return — - const better = higherIsBetter ? diff > 0 : diff < 0 - const color = better ? 'text-green-400' : 'text-red-400' - const sign = diff > 0 ? '+' : '' - return {sign}{fmt(diff)} +function PlayerSlot({ tag, player, onPick }: { tag: string; player: Player | null; onPick: (p: Player) => void }) { + const [searching, setSearching] = useState(false) + const show = searching || !player + return ( +
+ {player ? : } +
+
{tag}
+ {show ? ( + { onPick(p); setSearching(false) }} onCancel={player ? () => setSearching(false) : undefined} /> + ) : ( + <> +
{player.name}
+
+ + {player.team}·{player.nationality} +
+ + )} +
+ {player && !show && ( + <> +
+
Fantasy Score
+
{player.aggregated_scores.s_final.toFixed(2)}
+
+ + + )} +
+ ) } -function SearchInput({ label, onFound }: { label: string; onFound: (p: Player) => void }) { +function PlayerSearch({ onPick, onCancel }: { onPick: (p: Player) => void; onCancel?: () => void }) { + const { season } = useApp() const [query, setQuery] = useState('') const [results, setResults] = useState([]) - const [loading, setLoading] = useState(false) - const containerRef = useRef(null) + const ref = useRef(null) useEffect(() => { if (!query.trim()) { setResults([]); return } - const t = setTimeout(async () => { - setLoading(true) - try { - const data = await getPlayers({ name: query.trim(), page_size: 8 }) - setResults(data.data) - } catch { - setResults([]) - } finally { - setLoading(false) - } + const t = setTimeout(() => { + getPlayers({ name: query.trim(), page_size: 8, season }) + .then((d) => setResults(d.data)) + .catch(() => setResults([])) }, 300) return () => clearTimeout(t) - }, [query]) + }, [query, season]) useEffect(() => { - const onClickOutside = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setResults([]) - } + const close = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { setResults([]); onCancel?.() } } - document.addEventListener('mousedown', onClickOutside) - return () => document.removeEventListener('mousedown', onClickOutside) - }, []) - - const pick = (p: Player) => { - onFound(p) - setQuery('') - setResults([]) - } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [onCancel]) return ( -
- {label} +
+ setQuery(e.target.value)} - placeholder="Search by name…" - className="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm w-56" + onKeyDown={(e) => e.key === 'Escape' && onCancel?.()} + placeholder="Search a player..." + className="field h-9 w-full max-w-sm pl-9 pr-3" /> - {loading &&
Searching…
} {results.length > 0 && ( -
- {results.map(p => ( +
+ {results.map((p) => ( ))}
@@ -94,81 +190,3 @@ function SearchInput({ label, onFound }: { label: string; onFound: (p: Player) =
) } - -export default function Compare() { - const [playerA, setPlayerA] = useState(null) - const [playerB, setPlayerB] = useState(null) - - const scoreRow = (label: string, a: number, b: number, higherIsBetter = true) => ( - - = b : a <= b) && a !== b ? 'text-green-400' : 'text-gray-200' - }`}>{fmt(a)} - {label} - = a : b <= a) && a !== b ? 'text-green-400' : 'text-gray-200' - }`}>{fmt(b)} - - - ) - - return ( -
-

Compare Players

- -
- - -
- - {playerA && playerB && ( -
- {/* header */} -
-
-
{playerA.name}
-
{playerA.position_exact} · {playerA.team}
-
-
vs
-
-
{playerB.name}
-
{playerB.position_exact} · {playerB.team}
-
-
Δ (A−B)
-
- - - - - - - - - - - - {/* scores */} - {scoreRow('S_final', playerA.aggregated_scores.s_final, playerB.aggregated_scores.s_final)} - {scoreRow('Offensive', playerA.aggregated_scores.offensive, playerB.aggregated_scores.offensive)} - {scoreRow('Defensive', playerA.aggregated_scores.defensive, playerB.aggregated_scores.defensive)} - {scoreRow('Tactical', playerA.aggregated_scores.tactical, playerB.aggregated_scores.tactical)} - {/* stats */} - {STAT_LABELS.map(({ key, label, higherIsBetter }) => - scoreRow( - label, - playerA.aggregated_stats[key] as number, - playerB.aggregated_stats[key] as number, - higherIsBetter, - ) - )} - -
AStatBΔ
-
- )} - - {(playerA || playerB) && !(playerA && playerB) && ( -
Load both players to see the comparison.
- )} -
- ) -} diff --git a/frontend/src/pages/PlayerDetail.tsx b/frontend/src/pages/PlayerDetail.tsx deleted file mode 100644 index bac9900..0000000 --- a/frontend/src/pages/PlayerDetail.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useState } from 'react' -import { getPlayers, getPlayer, type Player } from '../api/players' -import PlayerModal from '../components/PlayerModal' - -type SearchState = 'idle' | 'loading' | 'done' | 'error' - -export default function PlayerDetail() { - const [query, setQuery] = useState('') - const [results, setResults] = useState([]) - const [state, setState] = useState('idle') - const [error, setError] = useState('') - const [modalPlayer, setModalPlayer] = useState(null) - - const search = async () => { - const q = query.trim() - if (!q) return - setState('loading') - setError('') - try { - const r = await getPlayers({ name: q, page_size: 20 }) - setResults(r.data) - setState('done') - } catch (e) { - setError(e instanceof Error ? e.message : 'Search failed') - setState('error') - } - } - - const selectPlayer = async (p: Player) => { - if (!p.sofascore_player_id) { setModalPlayer(p); return } - try { - setModalPlayer(await getPlayer(p.sofascore_player_id)) - } catch { - setModalPlayer(p) - } - } - - return ( -
-

Player Detail

-
- setQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && search()} - placeholder="Search player name…" - className="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm w-64" - /> - -
- - {state === 'loading' &&
Searching…
} - {state === 'error' &&
{error}
} - - {state === 'done' && results.length === 0 && ( -
No players found for "{query}".
- )} - - {state === 'done' && results.length > 0 && ( -
- {results.map((p) => ( - - ))} -
- )} - - setModalPlayer(null)} /> -
- ) -} diff --git a/frontend/src/pages/PlayerDetails.tsx b/frontend/src/pages/PlayerDetails.tsx new file mode 100644 index 0000000..dbfc1d8 --- /dev/null +++ b/frontend/src/pages/PlayerDetails.tsx @@ -0,0 +1,161 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { getPlayers, getPlayerCompetitions, serializeFilters, type Player, type PlayerList, type CompetitionList, type SortOrder } from '../api/players' +import { useApp } from '../context/AppContext' +import FilterBar from '../components/FilterBar' +import { EMPTY_FILTERS, clauseLabel, type Filters } from '../lib/filters' +import PlayerTable, { type Column } from '../components/PlayerTable' +import PlayerModal from '../components/PlayerModal' +import { COL } from '../components/playerColumns' +import { NoResults, TableSkeleton } from '../components/states/SystemStates' +import PageHeader from '../components/ui/PageHeader' +import Pagination from '../components/ui/Pagination' +import ScoreBar from '../components/ui/ScoreBar' + +const SEARCH_DEBOUNCE_MS = 250 +const PAGE_SIZE = 50 + +// Wait for a pause in typing so one keystroke does not mean one request. +function useDebounced(value: T, ms: number): T { + const [debounced, setDebounced] = useState(value) + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), ms) + return () => clearTimeout(timer) + }, [value, ms]) + return debounced +} + +const TEXT_FILTERS: { key: keyof Filters; label: string }[] = [ + { key: 'name', label: 'name' }, + { key: 'position', label: 'position' }, + { key: 'team', label: 'team' }, + { key: 'nationality', label: 'nationality' }, + { key: 'underpredicted_flag', label: 'flag' }, + { key: 'stats_view', label: 'view' }, +] + +export default function PlayerDetails() { + const { season } = useApp() + const [data, setData] = useState(null) + const [filters, setFilters] = useState(EMPTY_FILTERS) + const [sortBy, setSortBy] = useState('s_final') + const [order, setOrder] = useState('desc') + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [modalId, setModalId] = useState(null) + const [modalPlayer, setModalPlayer] = useState(null) + const [competitions, setCompetitions] = useState(undefined) + const requestId = useRef(0) + + const debouncedFilters = useDebounced(filters, SEARCH_DEBOUNCE_MS) + + useEffect(() => { + getPlayerCompetitions(season).then(setCompetitions).catch(console.error) + }, [season]) + + const openPlayer = (p: Player) => + p.sofascore_player_id ? setModalId(p.sofascore_player_id) : setModalPlayer(p) + const closeModal = () => { setModalId(null); setModalPlayer(null) } + + const load = useCallback(async () => { + const id = ++requestId.current + setLoading(true) + try { + const f = debouncedFilters + const params: Record = { season, page, page_size: PAGE_SIZE, sort_by: sortBy, order } + if (f.name) params.name = f.name + if (f.position) params.position = f.position + if (f.team) params.team = f.team + if (f.nationality) params.nationality = f.nationality + if (f.underpredicted_flag) params.underpredicted_flag = f.underpredicted_flag + if (f.stats_view) params.stats_view = f.stats_view + const serialized = serializeFilters(f.clauses) + if (serialized) params.filters = serialized + const result = await getPlayers(params) + // A slower earlier request must not overwrite a newer one's rows. + if (id === requestId.current) { setData(result); setError('') } + } catch (e) { + console.error(e) + // Drop the rows too: stale ones would read as results for the new search. + if (id === requestId.current) { setData(null); setError(e instanceof Error ? e.message : 'Search failed') } + } finally { + if (id === requestId.current) setLoading(false) + } + }, [debouncedFilters, page, sortBy, order, season]) + + const handleSort = (field: string) => { + if (field === sortBy) setOrder((o) => (o === 'desc' ? 'asc' : 'desc')) + else { setSortBy(field); setOrder('desc') } + setPage(1) + } + + const update = (f: Filters) => { setFilters(f); setPage(1) } + + // Hold every request until typing settles, so a page reset cannot fire an unfiltered one first. + const settling = filters !== debouncedFilters + useEffect(() => { if (!settling) load() }, [load, settling]) + + const active = [ + ...TEXT_FILTERS.filter((t) => filters[t.key]).map((t) => `${t.label}: ${filters[t.key]}`), + ...filters.clauses.map(clauseLabel), + ] + const removeLast = () => { + if (filters.clauses.length) return update({ ...filters, clauses: filters.clauses.slice(0, -1) }) + const last = [...TEXT_FILTERS].reverse().find((t) => filters[t.key]) + if (last) update({ ...filters, [last.key]: '' }) + } + + const maxScore = Math.max(...(data?.data.map((p) => p.aggregated_scores.s_final) ?? [0]), 0.01) + const columns: Column[] = [ + COL.rank, { ...COL.player, className: 'min-w-[220px]' }, COL.pos, COL.team, + { + key: 'score', label: 'Fantasy Score', sortKey: 's_final', + render: (p) => ( + + ), + }, + COL.apps, COL.goals, COL.assists, COL.xg, COL.xa, COL.minutes, COL.rating, COL.signal, + ] + + return ( +
+ Ranked by Fantasy ScoreS_final = raw/90 × starter × confidence + playing-time bonus} + right={data && ( + + {data.total.toLocaleString('en-US')} results + + )} + /> + + + + {error &&
{error}
} + {!data && !error && } + {data && data.total === 0 && ( + update(EMPTY_FILTERS)} + onRemoveLast={active.length ? removeLast : undefined} + /> + )} + {data && data.total > 0 && ( + <> + + + + )} + +
+ ) +} diff --git a/frontend/src/pages/Rankings.tsx b/frontend/src/pages/Rankings.tsx deleted file mode 100644 index 5a223ca..0000000 --- a/frontend/src/pages/Rankings.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' -import { getPlayers, getPlayerCompetitions, serializeFilters, type Player, type PlayerList, type CompetitionList, type SortOrder } from '../api/players' -import FilterBar, { type Filters } from '../components/FilterBar' -import PlayerTable from '../components/PlayerTable' -import PlayerModal from '../components/PlayerModal' - -export default function Rankings() { - const [data, setData] = useState(null) - const [filters, setFilters] = useState({ - position: '', team: '', nationality: '', underpredicted_flag: '', stats_view: '', clauses: [], - }) - const [sortBy, setSortBy] = useState('s_final') - const [order, setOrder] = useState('desc') - const [page, setPage] = useState(1) - const [loading, setLoading] = useState(false) - const [modalId, setModalId] = useState(null) - const [modalPlayer, setModalPlayer] = useState(null) - const [competitions, setCompetitions] = useState(undefined) - - useEffect(() => { - getPlayerCompetitions().then(setCompetitions).catch(console.error) - }, []) - - const openPlayer = (p: Player) => - p.sofascore_player_id ? setModalId(p.sofascore_player_id) : setModalPlayer(p) - const closeModal = () => { setModalId(null); setModalPlayer(null) } - - const load = useCallback(async () => { - setLoading(true) - try { - const params: Record = { page, page_size: 50, sort_by: sortBy, order } - if (filters.position) params.position = filters.position - if (filters.team) params.team = filters.team - if (filters.nationality) params.nationality = filters.nationality - if (filters.underpredicted_flag) params.underpredicted_flag = filters.underpredicted_flag - if (filters.stats_view && filters.stats_view !== 'all') params.stats_view = filters.stats_view - const serialized = serializeFilters(filters.clauses) - if (serialized) params.filters = serialized - setData(await getPlayers(params)) - } catch (e) { - console.error(e) - } finally { - setLoading(false) - } - }, [filters, page, sortBy, order]) - - const handleSortChange = (field: string) => { - if (field === sortBy) { - setOrder((o) => (o === 'desc' ? 'asc' : 'desc')) - } else { - setSortBy(field) - setOrder('desc') - } - setPage(1) - } - - useEffect(() => { load() }, [load]) - - return ( -
-
-

Player Rankings

-
- - { setFilters(f); setPage(1) }} - competitions={competitions} - /> - - {loading &&
Loading…
} - {data && !loading && ( - <> -
{data.total} players
- - - )} - -
- ) -} diff --git a/frontend/src/pages/ScatterPage.tsx b/frontend/src/pages/ScatterPage.tsx index a9bee2b..7ca77ca 100644 --- a/frontend/src/pages/ScatterPage.tsx +++ b/frontend/src/pages/ScatterPage.tsx @@ -1,40 +1,200 @@ -import { useState, useEffect } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { ArrowUpRight, MousePointerClick } from 'lucide-react' import { getScatterData, type ScatterPoint } from '../api/players' -import ScatterPlot from '../components/ScatterPlot' +import { useApp } from '../context/AppContext' import PlayerModal from '../components/PlayerModal' +import ScatterPlot, { type PlotPoint } from '../components/ScatterPlot' +import Avatar from '../components/ui/Avatar' +import { FlagBadge } from '../components/ui/Badges' +import { Select } from '../components/ui/Fields' +import PageHeader, { Dot } from '../components/ui/PageHeader' +import Segmented from '../components/ui/Segmented' +import StatRow from '../components/ui/StatRow' + +const X_AXES = [ + { value: 'xg_xa', label: 'xG + xA' }, + { value: 'xg', label: 'xG' }, + { value: 'xa', label: 'xA' }, +] as const +const Y_AXES = [ + { value: 'g_a', label: 'Goals + Assists' }, + { value: 'goals', label: 'Goals' }, + { value: 'assists', label: 'Assists' }, +] as const +const Y_SHORT: Record = { g_a: 'G + A', goals: 'Goals', assists: 'Assists' } + +type XKey = (typeof X_AXES)[number]['value'] +type YKey = (typeof Y_AXES)[number]['value'] +type Pos = 'ALL' | 'GK' | 'DF' | 'MF' | 'FW' + +const COLORS = { due: '#e9b44c', over: '#34d98c', inline: '#34423b', selected: '#f4f7f5' } +const LABELS_PER_FLAG = 3 export default function ScatterPage() { + const { season } = useApp() const [data, setData] = useState([]) - const [loading, setLoading] = useState(false) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + const [xKey, setXKey] = useState('xg_xa') + const [yKey, setYKey] = useState('g_a') + const [pos, setPos] = useState('ALL') + const [minMinutes, setMinMinutes] = useState('900') + const [highlight, setHighlight] = useState(true) + const [selectedId, setSelectedId] = useState(null) const [modalId, setModalId] = useState(null) useEffect(() => { setLoading(true) - getScatterData() - .then((r) => setData(r.data)) - .catch(console.error) + getScatterData(season) + .then((r) => { setData(r.data); setError(false) }) + .catch(() => setError(true)) .finally(() => setLoading(false)) - }, []) + }, [season]) + + const visible = useMemo(() => { + const min = Number(minMinutes) || 0 + return data.filter((p) => p.sofascore_player_id && p.minutes >= min && (pos === 'ALL' || p.position === pos)) + }, [data, minMinutes, pos]) + + const points = useMemo(() => { + // Label the widest gaps of each flag, plus the selected player. + const gap = (p: ScatterPoint) => Math.abs(p.xg_xa - p.g_a) + const top = (f: string) => + visible.filter((p) => p.flag === f).sort((a, b) => gap(b) - gap(a)).slice(0, LABELS_PER_FLAG) + const labelled = new Set(highlight ? [...top('HIGH_VALUE'), ...top('OVERPERFORMING')].map((p) => p.sofascore_player_id) : []) + const lastName = (n: string) => n.split(' ').slice(-1)[0] - const handlePointClick = (point: ScatterPoint) => { - if (point.sofascore_player_id) setModalId(point.sofascore_player_id) - } + const out = visible.map((p) => { + const selected = p.sofascore_player_id === selectedId + const flagged = highlight && p.flag + return { + id: p.sofascore_player_id!, + name: p.name, + x: p[xKey], + y: p[yKey], + color: selected ? COLORS.selected : !flagged ? COLORS.inline : p.flag === 'HIGH_VALUE' ? COLORS.due : COLORS.over, + radius: selected || flagged ? 5 : 3, + label: selected || labelled.has(p.sofascore_player_id) ? lastName(p.name) : undefined, + selected, + } + }) + // Draw grey points first so flagged and selected ones stay on top. + const rank = (p: PlotPoint) => (p.selected ? 2 : p.color === COLORS.inline ? 0 : 1) + return out.sort((a, b) => rank(a) - rank(b)) + }, [visible, xKey, yKey, highlight, selectedId]) + + const selected = data.find((p) => p.sofascore_player_id === selectedId) ?? null + const xLabel = X_AXES.find((a) => a.value === xKey)!.label + const yShort = Y_SHORT[yKey] return (
-

G+A vs xG+xA

-

- Points above the diagonal are overperforming their xG+xA. Points{' '} - below have higher expected output than actual — - underpredicted players. -

- {loading &&
Loading…
} - {!loading && ( -
- + Expected contribution against actual contributionthe diagonal is xGI = GI} + right={{visible.length.toLocaleString('en-US')} players plotted} + /> + +
+ + + + ({ value: p, label: p === 'ALL' ? 'All' : p }))} + value={pos} + onChange={setPos} + /> + + +
+ +
+
+ {loading &&
Loading…
} + {error &&
Could not load the scatter data.
} + {!loading && !error && ( + + )}
- )} + + +
+ setModalId(null)} />
) } + +function LegendItem({ color, title, text }: { color: string; title: string; text: string }) { + return ( +
+ +
+
{title}
+
{text}
+
+
+ ) +} diff --git a/frontend/src/pages/Sleepers.tsx b/frontend/src/pages/Sleepers.tsx index 898882a..4279a3b 100644 --- a/frontend/src/pages/Sleepers.tsx +++ b/frontend/src/pages/Sleepers.tsx @@ -1,63 +1,136 @@ import { useState, useEffect } from 'react' -import { getPlayers, type Player, type PlayerList } from '../api/players' -import PlayerTable from '../components/PlayerTable' +import { getPlayers, type Flag, type Player, type PlayerList } from '../api/players' +import { useApp } from '../context/AppContext' +import PlayerTable, { type Column } from '../components/PlayerTable' import PlayerModal from '../components/PlayerModal' +import { COL } from '../components/playerColumns' +import { TableSkeleton } from '../components/states/SystemStates' +import { FlagBadge } from '../components/ui/Badges' +import PageHeader, { Dot } from '../components/ui/PageHeader' +import Pagination from '../components/ui/Pagination' +import ScoreBar from '../components/ui/ScoreBar' +import Segmented from '../components/ui/Segmented' +import { signed, xgiGap } from '../lib/format' + +const PAGE_SIZE = 50 + +// Thresholds mirror backend/app/domain/sleeper_detector.py. +const RULES: { flag: Flag; formula: string; text: string; border: string }[] = [ + { + flag: 'HIGH_VALUE', + formula: 'xG + xA > 1.20 × (G + A)', + text: 'Underlying numbers outrun the returns. These players have historically regressed upward — the buy-low signal.', + border: 'border-l-warn', + }, + { + flag: 'OVERPERFORMING', + formula: 'G + A > 1.25 × (xG + xA)', + text: 'Returns outrun the underlying numbers. Finishing is running hot and is likely to cool — the sell-high signal.', + border: 'border-l-accent', + }, +] + +// Due: 1.0x -> empty bar, 1.8x -> full. Over: 1.0x -> empty, 0.2x -> full. +const ratioFraction = (r: number | null, flag: Flag) => + r == null ? 1 : flag === 'HIGH_VALUE' ? (r - 1) / 0.8 : (1 - r) / 0.8 export default function Sleepers() { + const { season } = useApp() const [data, setData] = useState(null) - const [flag, setFlag] = useState<'HIGH_VALUE' | 'OVERPERFORMING'>('HIGH_VALUE') + const [flag, setFlag] = useState('HIGH_VALUE') const [page, setPage] = useState(1) const [loading, setLoading] = useState(false) const [modalId, setModalId] = useState(null) - const [modalPlayer, setModalPlayer] = useState(null) - - const openPlayer = (p: Player) => - p.sofascore_player_id ? setModalId(p.sofascore_player_id) : setModalPlayer(p) - const closeModal = () => { setModalId(null); setModalPlayer(null) } useEffect(() => { + let live = true setLoading(true) - getPlayers({ underpredicted_flag: flag, page, page_size: 50 }) - .then(setData) + getPlayers({ + season, underpredicted_flag: flag, page, page_size: PAGE_SIZE, + sort_by: 'xratio', order: flag === 'HIGH_VALUE' ? 'desc' : 'asc', + }) + .then((d) => live && setData(d)) .catch(console.error) - .finally(() => setLoading(false)) - }, [flag, page]) + .finally(() => live && setLoading(false)) + return () => { live = false } + }, [flag, page, season]) + + const xg = 'font-mono text-accent' + const columns: Column[] = [ + COL.rank, { ...COL.player, className: 'min-w-[220px]' }, COL.pos, COL.team, + { + key: 'xratio', label: 'xRatio', sortKey: 'xratio', + render: (p) => { + const r = p.aggregated_scores.underpredicted_ratio + return + }, + }, + COL.apps, COL.goals, COL.assists, + { ...COL.xg, className: xg }, { ...COL.xa, className: xg }, + COL.minutes, + { key: 'score', label: 'Score', className: 'font-mono text-ink/90', render: (p) => p.aggregated_scores.s_final.toFixed(2) }, + { + key: 'signal', label: 'Signal', + render: (p: Player) => ( +
+ + {signed(xgiGap(p))} xGI +
+ ), + }, + ] return (
-

Underpredicted Picks

-

- Underpredicted: xG+xA > 1.2×(G+A) with >450 min — underperforming their underlying numbers.{' '} - Overperforming: G+A > 1.25×(xG+xA) with >450 min — scoring above expectation, likely to regress. -

-
- {(['HIGH_VALUE', 'OVERPERFORMING'] as const).map((f) => ( - + Players whose expected output and actual output have divergedgated on minutes > 450} + /> + +
+ {RULES.map((r) => ( +
+
+ + {r.formula} +
+

{r.text}

+
))}
- {loading &&
Loading…
} - {data && !loading && ( + +
+ { setFlag(f); setPage(1) }} + /> + {data && ( + + {data.total.toLocaleString('en-US')} players flaggedsorted by xRatio {flag === 'HIGH_VALUE' ? '▼' : '▲'} + + )} +
+ + {!data && } + {data && data.total === 0 && ( +

No players carry this flag in {season}.

+ )} + {data && data.total > 0 && ( <> -
{data.total} players
p.sofascore_player_id && setModalId(p.sofascore_player_id)} + sortBy="xratio" + order={flag === 'HIGH_VALUE' ? 'desc' : 'asc'} + dimmed={loading} /> + )} - + setModalId(null)} />
) } diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index a72b2f1..5c5a131 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,6 +1,28 @@ /** @type {import('tailwindcss').Config} */ export default { content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], - theme: { extend: {} }, + theme: { + extend: { + colors: { + bg: '#0a0f0d', + header: '#0e1411', + surface: '#111915', + 'surface-2': '#17211c', + line: '#1f2b25', + 'line-strong': '#2a3831', + ink: '#e7ede9', + muted: '#8a978f', + dim: '#56635c', + accent: { DEFAULT: '#34d98c', deep: '#1f9360', soft: '#11281d' }, + warn: { DEFAULT: '#e9b44c', soft: '#2e2412' }, + info: { DEFAULT: '#6fa8ea', soft: '#152235' }, + danger: { DEFAULT: '#e5564d', soft: '#2c1413' }, + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + mono: ['"JetBrains Mono"', 'ui-monospace', 'monospace'], + }, + }, + }, plugins: [], }