diff --git a/README.md b/README.md index c6bcb30..4d02a0e 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,50 @@ # MLB Stats Visualizer -Interactive MLB statistics visualization web application powered by -`python-mlb-statsapi`. +A local-first MLB statistics visualization application built with FastAPI, +Jinja2, Plotly, and `python-mlb-statsapi`. -## Status +> **Work in progress:** this project is under active development. The data model, +> visualizations, navigation, and deployment approach are still evolving. Expect +> features and UI details to change as the project grows. -**Milestone 0 — Repository Foundation** is complete. +The application imports MLB data explicitly, stores normalized team-game records +in SQLite, and renders interactive charts entirely from the local database. +Normal browser requests do **not** call the MLB Stats API. -**Milestone 1 — Team game-level data feasibility spike** is complete. +## What it does today -**Milestone 2 — Database persistence and team-season ingestion** is complete. +- Team Hits/Game trends with rolling and season averages +- Team batting Strikeouts/Game trends +- Team Runs/Game trends +- MLB-wide per-game comparisons when league coverage is trustworthy +- Normalized Hits vs batting Strikeouts comparison with MLB average = 100 +- Team, season, and rolling-window selectors with shareable URLs +- League-wide season ingestion with persisted completeness state +- Offline deterministic test suite and GitHub Actions CI -**Milestone 3 — Team hits web visualization** is complete. +Player visualizations, additional team metrics, multi-season overlays, and a more +organized Team/Player UI are planned but not implemented yet. -**Milestone 3.5 — Team batting strikeouts over time** is complete. +## Screenshots -**Milestone 4 — League-wide season ingestion and completeness** is complete. +The UI is still being refined. Current desktop and mobile screenshots will live +here as the design stabilizes. -Milestone 0 provides a FastAPI application with Jinja2 templates, Pydantic -Settings configuration, pytest coverage, Ruff linting/formatting, and GitHub -Actions CI. + ## Technology stack | Layer | Choice | | --- | --- | -| Language | Python 3.12 | +| Language | Python 3.12+ | | Packaging | Poetry | | Web framework | FastAPI | | Templates | Jinja2 | @@ -77,22 +54,16 @@ A local web application that: | ORM | SQLAlchemy 2 | | Migrations | Alembic | | Configuration | Pydantic Settings | -| Testing | pytest, httpx | +| Testing | pytest | | Lint / format | Ruff | | CI | GitHub Actions | -## Requirements - -- Python 3.12+ -- [Poetry](https://python-poetry.org/docs/#installation) - ## Installation -Install Poetry if you do not already have it: +Requirements: -```bash -curl -sSL https://install.python-poetry.org | python3 - -``` +- Python 3.12+ +- [Poetry](https://python-poetry.org/docs/#installation) Clone the repository and install dependencies: @@ -102,340 +73,191 @@ cd python-mlb-visualizer poetry install ``` -Optionally copy the example environment file (defaults work without it): +Optionally create a local environment file: ```bash cp .env.example .env ``` -### Database configuration - -Default database URL (no `.env` required): +The default database is: ```text sqlite:///./mlb_visualizer.db ``` -Override with `DATABASE_URL` in `.env` or the environment. Alembic and the -application read the same `Settings.database_url` value. - -Apply the schema: +Apply migrations: ```bash poetry run alembic upgrade head ``` -The database file is created relative to the project root when first used. +## Import data -## Local development +The web application reads SQLite only. MLB network access happens through +explicit import commands. -Apply migrations, import at least one team-season, then start the server: +Import one team-season: ```bash -poetry run alembic upgrade head poetry run python scripts/import_team_season.py --team-id 136 --season 2025 -poetry run uvicorn app.main:app --reload ``` -Then open [http://127.0.0.1:8000](http://127.0.0.1:8000). - -- `/` — team hitting trends (hits per game) -- `/strikeouts` — team batting strikeout trends -- `/runs` — team run scoring trends (runs scored per game) -- `/health` — JSON health check - -## Team hitting trends page +Import an entire MLB season: -The homepage charts one team's hits per game for one season, with a trailing -rolling average and the team's season average. - -```text -http://127.0.0.1:8000/?team_id=136&season=2025&window=15 +```bash +poetry run python scripts/import_league_season.py --season 2025 ``` -| Parameter | Meaning | Default | -| --- | --- | --- | -| `team_id` | MLB team id that has been imported | Seattle (136) when stored, otherwise the first team alphabetically | -| `season` | season imported for that team | the most recent stored season | -| `window` | rolling window: `5`, `10`, `15`, or `30` | `15` | - -Submitting the controls produces a shareable URL, so a chart can be linked -directly. Choosing a different team updates the season selector in the browser -to that team's stored seasons, so the form cannot submit a combination that has -no data. The route validates the pair on every request regardless. +League imports record whether every discovered team was refreshed successfully. +MLB-wide comparison statistics are only presented when the persisted coverage +state supports describing the stored data as league-wide. -**The page reads SQLite only.** No web request touches the MLB Stats API; -importing data is always the explicit CLI step above. Selectors list only the -team-seasons that are actually stored locally. +Imports are idempotent: unchanged rows stay unchanged, changed rows are updated, +and already-stored rows are not deleted simply because a later upstream response +omits them. -**Rolling average.** Trailing, not centered: the value at game N averages the -`window` most recent games including game N. Early-season games average every -game played so far rather than showing a gap. The line joins the calculated -points with straight segments, so it never implies an average between games. +## Run locally -**Every number describes the stored games.** That may be a season in progress -or a partial import, so the dashed reference line is the team's average across -the completed games currently stored, not a guaranteed full season. The -footer's "Data through" date shows how current the numbers are. - -**No MLB-wide average.** The database holds only explicitly imported -team-seasons, so a league average calculated from it would describe whichever -teams happen to be stored rather than the league. The third series is the -team's own stored-season average. League comparison is deferred until -league-wide ingestion is defined. - -If the database is empty, the page explains how to import a team-season. If -migrations have not been applied, it asks for `poetry run alembic upgrade head` -instead of failing with a traceback. - -## Team batting strikeout trends page +```bash +poetry run uvicorn app.main:app --reload +``` -`/strikeouts` charts one team's **batting** strikeouts per game for one season, -with a trailing rolling average and the team's season average. +Open: ```text -http://127.0.0.1:8000/strikeouts?team_id=136&season=2025&window=15 +http://127.0.0.1:8000 ``` -It takes the same `team_id`, `season`, and `window` parameters as the hits page, -with the same defaults, and the navigation carries the current selection between -the two pages. `/` is unchanged and still serves hits. - -**Batting, not pitching.** Every label says "Batting Strikeouts": these are -times the team's own hitters struck out, not strikeouts recorded by its -pitchers. - -**Direction is not labelled good or bad.** A positive "vs Prior {window}" value -means more batting strikeouts. Whether that is bad depends on the question and -on what else the offense is doing, so no positive/negative colouring is applied. - -**K/Game is a count, not a rate.** Games contain different numbers of plate -appearances, so a game with more opportunities can show more strikeouts without -hitters striking out any more often. K% (strikeouts per plate appearance) is the -opportunity-adjusted measure and is deferred until plate appearances are -persisted; it is not estimated in the meantime. - -### Re-importing for batting strikeouts +Current routes: -Batting strikeouts were added in Milestone 3.5, so team-seasons imported before -it have no stored strikeout totals. Those totals are **unknown, not zero**, so -the migration leaves them `NULL` rather than defaulting them. +| Route | Visualization | +| --- | --- | +| `/` | Team Hits/Game | +| `/strikeouts` | Team batting Strikeouts/Game | +| `/runs` | Team Runs/Game | +| `/comparison` | Normalized Hits vs batting Strikeouts | +| `/health` | JSON health check | -If a selected team-season still has unimported strikeouts, `/strikeouts` -explains that and shows the command for that exact team and season instead of -charting anything: +The chart routes support: -```bash -poetry run python scripts/import_team_season.py --team-id 136 --season 2025 +```text +team_id= +season= +window=5|10|15|30 ``` -The same import command as always — there is no separate strikeout import. The -first re-import counts those rows as **updated**; running it again against -unchanged MLB data counts them as **unchanged**. - -The hits page keeps working normally throughout, before and after the backfill. - -## Team run scoring trends page - -`/runs` charts one team's **runs scored** per game for one season, with a -trailing rolling average, the team's season average, and — when league coverage -allows — an MLB average. +Example: ```text -http://127.0.0.1:8000/runs?team_id=136&season=2025&window=15 +http://127.0.0.1:8000/comparison?team_id=136&season=2025&window=15 ``` -It takes the same `team_id`, `season`, and `window` parameters as the other -metric pages, with the same defaults, and the navigation carries the current -selection between all three. `/` and `/strikeouts` are unchanged. - -**Runs scored, not runs allowed.** Every label says so. Nothing on this page is -a run differential, and the runs a team gave up are not stored or shown. - -**Team Runs/Game** is total runs scored divided by the number of stored -completed team-game records for that team-season. One value: the Season Avg -card, the dashed reference line, and the MLB comparison all read it. - -**MLB Runs/Game** is total runs across all persisted team-game records for the -season divided by the total number of those records — game-weighted, so a club -that has played more games counts for more. One real MLB game contributes two -team-game records, one per club, so both sides of the comparison are per-team -per-game numbers. Nothing assumes 30 teams, 162 games, or any fixed record -count. - -**The MLB average is gated on coverage.** It is shown only when a league-season -import recorded `COMPLETE` coverage for that season. `INCOMPLETE`, `RUNNING`, -and a season no league import has touched all show no MLB line and a `vs MLB` -card reading `—`, never `0.00`. The team's own chart renders in every one of -those states. `COMPLETE` describes the refresh, not the season, so an -in-progress season with complete coverage does show a comparison, calculated -from the games currently stored. - -**`vs MLB` is descriptive subtraction.** Positive means the team scored more -runs per game than MLB overall; negative, fewer. It is not a rank, a -percentile, a significance test, or a park- or opponent-adjusted figure. See -[docs/team-runs-visualization.md](docs/team-runs-visualization.md) for the -formulas and the limitations. - -**No re-import is needed.** Unlike batting strikeouts, `runs` has been a -required non-negative column since the first migration, so every stored -team-season already has real run totals. This page added no column and no -migration. - -## Team game-level hitting data - -Milestone 1 retrieves one normalized batting line per completed regular-season -game for a selected team and season. The reusable code lives in -`app/services/team_game_logs.py` and `app/schemas/games.py`. - -```python -from app.services.team_game_logs import get_team_game_batting_lines - -lines = get_team_game_batting_lines(team_id=136, season=2025) -``` +## Statistics and interpretation -### Inspection command (no persistence) +### Rolling averages -```bash -poetry run python scripts/inspect_game_logs.py --team-id 136 --season 2025 -``` +Rolling values are trailing averages that include the current game. Before a +full window exists, the application uses every completed game available so far. -Options: `--team-id` and `--season` are required; `--limit N` shows only the -first N games and `--format json` emits Pydantic-serialized JSON on stdout. +### MLB comparisons -This script calls the live MLB Stats API and is not part of `poetry run pytest`. +League averages are game-weighted over persisted team-game records, not an +unweighted mean of team averages. League statistics are shown only when the +stored league-season ingestion state indicates complete coverage. -### Team-season import (Milestone 2) +A `COMPLETE` refresh does **not** mean the baseball season has ended. It means +every discovered club was successfully refreshed for that import run. -Persist one team-season after migrations: +### Batting strikeouts -```bash -poetry run alembic upgrade head -poetry run python scripts/import_team_season.py --team-id 136 --season 2025 -``` +Strikeouts shown by this application are **batting strikeouts** by the selected +team's hitters, not pitching strikeouts. -First run on an empty table inserts every fetched game: +Batting K/Game is a per-game count, not K%. Plate appearances are not currently +persisted, so the application does not estimate K%. -```text -Team: Seattle Mariners -Season: 2025 -Fetched: 162 -Inserted: 162 -Updated: 0 -Unchanged: 0 -``` +### Normalized comparison -A second identical run is idempotent: +The comparison page puts two different statistics on a common scale: ```text -Fetched: 162 -Inserted: 0 -Updated: 0 -Unchanged: 162 -``` - -JSON output: +Hits Index = rolling team Hits/Game / MLB Hits/Game * 100 -```bash -poetry run python scripts/import_team_season.py \ - --team-id 136 --season 2025 --format json +Batting Strikeout Index = rolling team batting K/Game + / MLB batting K/Game + * 100 ``` -**Unique key:** `(team_id, game_pk)` — one row per team per game. - -**Upsert behavior:** compare persisted baseball fields; insert new rows, update -changed rows, leave identical rows untouched (`updated_at` does not change on -unchanged rows). - -**No automatic deletion:** rows already stored but missing from the latest MLB -response are kept. See `docs/team-season-ingestion.md`. +`100` means MLB average for that metric. Above 100 means more of the named +statistic than MLB average; it does not automatically mean better performance. -### League-season import (Milestone 4) - -Import every MLB team for one season after migrations: - -```bash -poetry run alembic upgrade head -poetry run python scripts/import_league_season.py --season 2025 -``` +The displayed Trend Gap is: ```text -MLB League Import — 2025 -Teams discovered: 30 -[ 1/30] Athletics ................. unchanged -[ 2/30] Los Angeles Angels ........ updated -... -[30/30] Washington Nationals ...... inserted -Season: 2025 -Teams discovered: 30 -Teams succeeded: 30 -Teams failed: 0 -Team-game records fetched: 4860 -Inserted: 0 -Updated: 4860 -Unchanged: 0 -Ingestion coverage: COMPLETE +recent Hits Index - recent Batting Strikeout Index ``` -Counts above are illustrative; real values come from the run. +It is descriptive arithmetic, not a validated overall offensive-performance +metric, ranking, percentile, significance test, or causal claim. -JSON output, for a future scheduler or deployment tool: +## Architecture -```bash -poetry run python scripts/import_league_season.py --season 2025 --format json +```text +MLB Stats API + ↓ +python-mlb-statsapi + ↓ +services / normalization + ↓ +domain schemas + ↓ +repositories / SQLite + ↓ +analytics + ↓ +FastAPI + Jinja2 + Plotly + ↓ +browser ``` -With `--format json`, stdout carries only the serialized result. Progress and -operational errors go to stderr. - -**Exit codes:** `0` complete coverage, `1` the run could not be carried out -(invalid season, discovery failure, coverage state not persisted), `2` the run -finished with at least one failed club. - -**Team discovery:** `Mlb.get_teams(sport_id=1, season=)`. No list of -current team ids is hardcoded anywhere. - -**Team-game records, not games:** one MLB game produces two team batting lines. -A 30-team, 162-game season is 4,860 team-game records — 2,430 games. - -**Coverage, not season finality:** `COMPLETE` means every discovered club was -successfully refreshed by that run. It does not assert the regular season has -finished being played. - -**Partial failure:** clubs that succeeded stay committed; the run is recorded -`INCOMPLETE` and a rerun is safe and idempotent. - -**Not connected to the web app:** loading a page never triggers an import, and -no admin ingestion route was added. - -Live MLB access was unavailable in the environment this milestone was built in, -so the 2025 import was not run against the real API. See -`docs/league-season-ingestion.md` for what that leaves unverified. +Key rules: -### Selected retrieval strategy +- Browser requests are database-only. +- MLB network access belongs to explicit ingestion workflows. +- Baseball calculations live in analytics, not routes or repositories. +- Routes stay thin and server-rendered. +- Missing data is treated as unknown rather than silently converted to zero. +- League completeness is persisted explicitly rather than inferred from row counts. -Team hitting `gameLog` splits joined on `gamePk` with a single team schedule -request. See `docs/team-game-data-spike.md` for the full investigation. +See [`AGENTS.md`](AGENTS.md) for the project architecture and contribution rules. -### Cross-source validation +## Project layout -The batting numbers come from the game log and the game context comes from the -schedule. Disagreements raise `TeamGameDataError`. - -### Edge cases and limitations - -Same as Milestone 1 (completed states, doubleheaders, postponements, etc.). +```text +app/ +├── analytics/ # baseball calculations +├── database/ # SQLAlchemy models, repositories, engine +├── schemas/ # typed domain and analytics contracts +├── services/ # MLB retrieval, normalization, ingestion +└── web/ # FastAPI routes, Plotly figures, templates, static assets + +scripts/ # operational import / inspection commands +docs/ # design and statistical documentation +alembic/ # database migrations +tests/ # offline deterministic tests +``` ## Testing +Run the full suite: + ```bash poetry run pytest ``` -The suite is fully offline. Migration and repository tests use temporary SQLite -files and real Alembic upgrades. +The automated test suite is designed to run without live MLB network access. -## Lint and formatting +Lint and formatting: ```bash poetry run ruff check . @@ -443,123 +265,19 @@ poetry run ruff format . poetry run ruff format --check . ``` -## Project structure - -```text -. -├── alembic/ -│ ├── versions/ -│ │ └── 166b6424e4f9_create_team_game_batting_lines.py -│ ├── env.py -│ └── script.py.mako -├── alembic.ini -├── app/ -│ ├── __init__.py -│ ├── main.py -│ ├── config.py -│ ├── analytics/ -│ │ ├── __init__.py -│ │ └── team_hitting.py -│ ├── database/ -│ │ ├── __init__.py -│ │ ├── base.py -│ │ ├── engine.py -│ │ ├── models.py -│ │ └── repositories.py -│ ├── schemas/ -│ │ ├── __init__.py -│ │ ├── analytics.py -│ │ ├── catalog.py -│ │ ├── games.py -│ │ ├── ingestion.py -│ │ └── teams.py -│ ├── services/ -│ │ ├── __init__.py -│ │ ├── league_season_ingestion.py -│ │ ├── league_teams.py -│ │ ├── team_game_logs.py -│ │ └── team_season_ingestion.py -│ └── web/ -│ ├── __init__.py -│ ├── charts.py -│ ├── dependencies.py -│ ├── errors.py -│ ├── formatting.py -│ ├── routes.py -│ ├── selection.py -│ ├── static/ -│ │ ├── css/ -│ │ │ └── app.css -│ │ └── js/ -│ │ └── season-selector.js -│ └── templates/ -│ ├── base.html -│ ├── error.html -│ ├── index.html -│ ├── runs.html -│ └── strikeouts.html -├── scripts/ -│ ├── import_league_season.py -│ ├── inspect_game_logs.py -│ └── import_team_season.py -├── docs/ -│ ├── league-season-ingestion.md -│ ├── team-game-data-spike.md -│ ├── team-hits-visualization.md -│ ├── team-runs-visualization.md -│ ├── team-season-ingestion.md -│ ├── team-strikeouts-visualization.md -│ └── team-vs-mlb-comparison.md -├── tests/ -│ ├── conftest.py -│ ├── factories.py -│ ├── fixtures/ -│ │ └── team_game_logs/ -│ ├── test_analytics_league_hitting.py -│ ├── test_analytics_schemas.py -│ ├── test_analytics_team_hitting.py -│ ├── test_charts.py -│ ├── test_formatting.py -│ ├── test_game_schemas.py -│ ├── test_import_league_season.py -│ ├── test_import_team_season.py -│ ├── test_ingestion_schemas.py -│ ├── test_league_season_ingestion.py -│ ├── test_league_teams.py -│ ├── test_migrations.py -│ ├── test_repositories.py -│ ├── test_repositories_catalog.py -│ ├── test_repositories_league.py -│ ├── test_repositories_league_season.py -│ ├── test_selection.py -│ ├── test_team_game_logs.py -│ ├── test_team_season_ingestion.py -│ ├── test_web.py -│ └── test_web_league_comparison.py -├── .github/ -│ └── workflows/ -│ └── test.yml -├── .env.example -├── .gitignore -├── poetry.lock -├── pyproject.toml -└── README.md -``` - -## Later milestones - -Milestone 5 delivered the MLB hits-per-game comparison the Milestone 4 coverage -state unblocked, gated on that state exactly as planned. See -[docs/team-vs-mlb-comparison.md](docs/team-vs-mlb-comparison.md). +## Documentation -Issue #23 extended the same comparison to batting strikeouts, and issue #24 -added the runs page with it. All three read one shared coverage rule, so they -cannot disagree about whether a season may be described as MLB-wide. +More detailed implementation and statistical notes live in [`docs/`](docs/), +including: -Still unimplemented, and each needing its own definition before it is drawn: -league rank, percentiles, and normalized indexes. Any of them must check a -season's stored ingestion coverage before presenting a league statistic, the -way the existing comparisons do. +- [Team hits visualization](docs/team-hits-visualization.md) +- [Team batting strikeouts visualization](docs/team-strikeouts-visualization.md) +- [Team runs visualization](docs/team-runs-visualization.md) +- [Team vs MLB comparison](docs/team-vs-mlb-comparison.md) +- [Normalized hitting trends comparison](docs/team-hitting-trends-comparison.md) +- [League-season ingestion](docs/league-season-ingestion.md) +- [Team-season ingestion](docs/team-season-ingestion.md) +- [Team game data investigation](docs/team-game-data-spike.md) ## Disclaimer diff --git a/app/analytics/__init__.py b/app/analytics/__init__.py index 0be1444..6ddc6e0 100644 --- a/app/analytics/__init__.py +++ b/app/analytics/__init__.py @@ -5,6 +5,11 @@ TeamHitsAnalysisError, build_team_hits_analysis, ) +from app.analytics.team_hitting_comparison import ( + InvalidComparisonBaselineError, + TeamHittingComparisonError, + build_team_hitting_comparison_analysis, +) from app.analytics.team_runs import ( TeamRunsAnalysisError, build_team_runs_analysis, @@ -17,11 +22,14 @@ __all__ = [ "DEFAULT_ROLLING_WINDOW", + "InvalidComparisonBaselineError", "MissingStrikeoutDataError", "TeamHitsAnalysisError", + "TeamHittingComparisonError", "TeamRunsAnalysisError", "TeamStrikeoutsAnalysisError", "build_team_hits_analysis", + "build_team_hitting_comparison_analysis", "build_team_runs_analysis", "build_team_strikeouts_analysis", ] diff --git a/app/analytics/team_hitting_comparison.py b/app/analytics/team_hitting_comparison.py new file mode 100644 index 0000000..62c241f --- /dev/null +++ b/app/analytics/team_hitting_comparison.py @@ -0,0 +1,200 @@ +"""Normalized rolling team hits-versus-batting-strikeouts comparison. + +This module combines the existing, independently calculated team Hits/Game and +batting K/Game rolling analyses. Each rolling value is divided by its matching +MLB per-game context and multiplied by 100, so MLB average is 100 on both +scales. The indexes are descriptive only: above 100 means more of the named +statistic than the MLB baseline, not automatically better performance. + +The module is deliberately narrow. It does not query persistence, decide +whether league coverage is complete, build a Plotly figure, or generalize the +two statistics into a metric framework. +""" + +from app.schemas.analytics import ( + LeagueHitsContext, + LeagueStrikeoutsContext, + TeamHitsAnalysis, + TeamHittingComparisonAnalysis, + TeamHittingComparisonPoint, + TeamHittingComparisonSummary, + TeamStrikeoutsAnalysis, +) + +NORMALIZED_INDEX_BASELINE = 100.0 + + +class TeamHittingComparisonError(ValueError): + """The supplied analyses cannot form one trustworthy comparison.""" + + +class InvalidComparisonBaselineError(TeamHittingComparisonError): + """An MLB per-game baseline is not positive, so division is unsafe.""" + + def __init__(self, *, metric: str, value: float) -> None: + self.metric = metric + self.value = value + super().__init__( + f"MLB {metric} baseline must be greater than zero to calculate a " + f"normalized index, got {value}" + ) + + +def build_team_hitting_comparison_analysis( + hits_analysis: TeamHitsAnalysis, + strikeouts_analysis: TeamStrikeoutsAnalysis, + league_hits: LeagueHitsContext, + league_strikeouts: LeagueStrikeoutsContext, +) -> TeamHittingComparisonAnalysis: + """Normalize rolling team Hits/Game and batting K/Game to MLB = 100. + + For each aligned rolling point:: + + Hits Index = rolling team Hits/Game / MLB Hits/Game * 100 + Batting Strikeout Index = rolling team batting K/Game + / MLB batting K/Game * 100 + + ``trend_gap`` is the latest Hits Index minus the latest batting strikeout + index. It is only a difference between two normalized indexes, not a + validated overall offensive-performance statistic. + + Coverage is intentionally decided before this function is called. The + caller must only build the two league contexts after the existing complete + league-coverage checks pass; constructing the batting-strikeout context + additionally refuses any stored record with an unknown strikeout total. + + Raises + ------ + InvalidComparisonBaselineError + Either MLB per-game baseline is zero or otherwise non-positive. + TeamHittingComparisonError + The team analyses do not describe the same team, season, rolling + window, or ordered games, or a league context is for another season. + """ + _validate_analysis_identity(hits_analysis, strikeouts_analysis) + _validate_league_seasons(hits_analysis, league_hits, league_strikeouts) + _validate_positive_baselines(league_hits, league_strikeouts) + + points = tuple( + TeamHittingComparisonPoint( + game_pk=hits_point.game_pk, + season_game_number=hits_point.season_game_number, + game_date=hits_point.game_date, + opponent_name=hits_point.opponent_name, + hits_index=( + hits_point.rolling_average + / league_hits.hits_per_game + * NORMALIZED_INDEX_BASELINE + ), + strikeouts_index=( + strikeouts_point.rolling_average + / league_strikeouts.strikeouts_per_game + * NORMALIZED_INDEX_BASELINE + ), + ) + for hits_point, strikeouts_point in zip( + hits_analysis.points, + strikeouts_analysis.points, + strict=True, + ) + ) + recent = points[-1] + + return TeamHittingComparisonAnalysis( + team_id=hits_analysis.team_id, + team_name=hits_analysis.team_name, + season=hits_analysis.season, + rolling_window=hits_analysis.rolling_window, + mlb_hits_per_game=league_hits.hits_per_game, + mlb_strikeouts_per_game=league_strikeouts.strikeouts_per_game, + baseline_index=NORMALIZED_INDEX_BASELINE, + points=points, + summary=TeamHittingComparisonSummary( + games_played=len(points), + recent_hits_index=recent.hits_index, + recent_strikeouts_index=recent.strikeouts_index, + trend_gap=recent.hits_index - recent.strikeouts_index, + ), + ) + + +def _validate_analysis_identity( + hits_analysis: TeamHitsAnalysis, + strikeouts_analysis: TeamStrikeoutsAnalysis, +) -> None: + hits_identity = ( + hits_analysis.team_id, + hits_analysis.team_name, + hits_analysis.season, + hits_analysis.rolling_window, + ) + strikeouts_identity = ( + strikeouts_analysis.team_id, + strikeouts_analysis.team_name, + strikeouts_analysis.season, + strikeouts_analysis.rolling_window, + ) + if hits_identity != strikeouts_identity: + raise TeamHittingComparisonError( + "Hits and batting strikeout analyses must describe the same team, " + "season, and rolling window" + ) + + if len(hits_analysis.points) != len(strikeouts_analysis.points): + raise TeamHittingComparisonError( + "Hits and batting strikeout analyses must contain the same games" + ) + + for hits_point, strikeouts_point in zip( + hits_analysis.points, + strikeouts_analysis.points, + strict=True, + ): + hits_game = ( + hits_point.game_pk, + hits_point.game_number, + hits_point.season_game_number, + hits_point.game_date, + hits_point.opponent_name, + hits_point.home_away, + ) + strikeouts_game = ( + strikeouts_point.game_pk, + strikeouts_point.game_number, + strikeouts_point.season_game_number, + strikeouts_point.game_date, + strikeouts_point.opponent_name, + strikeouts_point.home_away, + ) + if hits_game != strikeouts_game: + raise TeamHittingComparisonError( + "Hits and batting strikeout analyses must contain the same " + "games in the same order" + ) + + +def _validate_league_seasons( + hits_analysis: TeamHitsAnalysis, + league_hits: LeagueHitsContext, + league_strikeouts: LeagueStrikeoutsContext, +) -> None: + if not (hits_analysis.season == league_hits.season == league_strikeouts.season): + raise TeamHittingComparisonError( + "Team and MLB hitting contexts must describe the same season" + ) + + +def _validate_positive_baselines( + league_hits: LeagueHitsContext, + league_strikeouts: LeagueStrikeoutsContext, +) -> None: + if league_hits.hits_per_game <= 0: + raise InvalidComparisonBaselineError( + metric="Hits/Game", + value=league_hits.hits_per_game, + ) + if league_strikeouts.strikeouts_per_game <= 0: + raise InvalidComparisonBaselineError( + metric="batting K/Game", + value=league_strikeouts.strikeouts_per_game, + ) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 31dc105..f237aef 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -4,6 +4,9 @@ TeamHitsAnalysis, TeamHitsPoint, TeamHitsSummary, + TeamHittingComparisonAnalysis, + TeamHittingComparisonPoint, + TeamHittingComparisonSummary, TeamStrikeoutsAnalysis, TeamStrikeoutsPoint, TeamStrikeoutsSummary, @@ -14,6 +17,9 @@ __all__ = [ "AvailableTeamSeason", "TeamGameBattingLine", + "TeamHittingComparisonAnalysis", + "TeamHittingComparisonPoint", + "TeamHittingComparisonSummary", "TeamHitsAnalysis", "TeamHitsPoint", "TeamHitsSummary", diff --git a/app/schemas/analytics.py b/app/schemas/analytics.py index bb4eb97..4320f7a 100644 --- a/app/schemas/analytics.py +++ b/app/schemas/analytics.py @@ -412,6 +412,131 @@ def _comparison_is_internally_consistent(self) -> TeamStrikeoutsLeagueComparison return self +class TeamHittingComparisonPoint(BaseModel): + """Normalized rolling hits and batting strikeouts for one completed game. + + Both indexes use their own MLB per-game average as the 100 baseline. The + values are descriptive: an index above 100 means more of that statistic + than the MLB baseline, which is not automatically favourable. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + game_pk: int = Field(gt=0, description="MLB game identifier.") + season_game_number: int = Field( + ge=1, + description="Continuous 1-based position of the game within the season.", + ) + game_date: date = Field(description="Official date the game counts against.") + opponent_name: str = Field( + min_length=1, description="Display name of the opponent." + ) + hits_index: float = Field( + ge=0, + description="Rolling team Hits/Game divided by MLB Hits/Game, times 100.", + ) + strikeouts_index: float = Field( + ge=0, + description="Rolling team batting K/Game divided by MLB batting K/Game, " + "times 100.", + ) + + +class TeamHittingComparisonSummary(BaseModel): + """Headline values for the normalized hitting comparison. + + ``trend_gap`` is only the arithmetic difference between the two most + recent normalized indexes. It is not an overall offensive-performance + statistic, ranking, percentile, or causal claim. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + games_played: int = Field(ge=1, description="Completed games analysed.") + recent_hits_index: float = Field( + ge=0, description="Hits index at the most recent rolling point." + ) + recent_strikeouts_index: float = Field( + ge=0, + description="Batting strikeout index at the most recent rolling point.", + ) + trend_gap: float = Field(description="recent_hits_index - recent_strikeouts_index.") + + @model_validator(mode="after") + def _trend_gap_matches_recent_indexes(self) -> TeamHittingComparisonSummary: + expected = self.recent_hits_index - self.recent_strikeouts_index + if not isclose(self.trend_gap, expected, rel_tol=1e-9, abs_tol=1e-9): + raise ValueError( + f"trend_gap ({self.trend_gap}) must equal recent_hits_index - " + f"recent_strikeouts_index ({expected})" + ) + return self + + +class TeamHittingComparisonAnalysis(BaseModel): + """A team-season's normalized rolling hits-versus-strikeouts trend.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + team_id: int = Field(gt=0, description="MLB team id.") + team_name: str = Field(min_length=1, description="Historical name for the season.") + season: int = Field(gt=0, description="Season analysed.") + rolling_window: int = Field(ge=1, description="Games in the trailing window.") + mlb_hits_per_game: float = Field( + gt=0, description="Positive MLB Hits/Game denominator used for every point." + ) + mlb_strikeouts_per_game: float = Field( + gt=0, + description="Positive MLB batting K/Game denominator used for every point.", + ) + baseline_index: float = Field( + default=100.0, + description="MLB average on both normalized index scales. Always 100.", + ) + points: tuple[TeamHittingComparisonPoint, ...] = Field( + min_length=1, description="Games in chart order." + ) + summary: TeamHittingComparisonSummary + + @model_validator(mode="after") + def _comparison_is_internally_consistent( + self, + ) -> TeamHittingComparisonAnalysis: + if not isclose(self.baseline_index, 100.0, rel_tol=0.0, abs_tol=1e-12): + raise ValueError("baseline_index must equal 100") + if self.summary.games_played != len(self.points): + raise ValueError( + "summary.games_played must equal the number of chart points" + ) + + recent = self.points[-1] + if not isclose( + self.summary.recent_hits_index, + recent.hits_index, + rel_tol=1e-9, + abs_tol=1e-9, + ): + raise ValueError( + "summary.recent_hits_index must equal the final point's hits_index" + ) + if not isclose( + self.summary.recent_strikeouts_index, + recent.strikeouts_index, + rel_tol=1e-9, + abs_tol=1e-9, + ): + raise ValueError( + "summary.recent_strikeouts_index must equal the final point's " + "strikeouts_index" + ) + return self + + @property + def last_game_date(self) -> date: + """Date of the most recent completed game in the comparison.""" + return self.points[-1].game_date + + class TeamRunsPoint(BaseModel): """One completed game plotted on the team runs chart.""" diff --git a/app/web/charts.py b/app/web/charts.py index 52623ba..a52d97a 100644 --- a/app/web/charts.py +++ b/app/web/charts.py @@ -3,11 +3,11 @@ Kept out of the route so the figure contract can be tested without HTTP and so the route stays about request handling. -The hits, batting strikeout, and runs figures are built by separate functions -that share only the rendering helpers below. They look alike, but a single -parameterized builder would have to encode which labels, colours, and axis -semantics belong to which statistic, which is harder to read than three -explicit builders. +The hits, batting strikeout, runs, and normalized comparison figures are built +by separate functions that share only the rendering helpers below. They look +alike, but a single parameterized builder would have to encode which labels, +colours, and axis semantics belong to which statistic, which is harder to read +than four explicit builders. """ from datetime import date @@ -20,6 +20,7 @@ from app.schemas.analytics import ( TeamHitsAnalysis, TeamHitsLeagueComparison, + TeamHittingComparisonAnalysis, TeamRunsAnalysis, TeamRunsLeagueComparison, TeamStrikeoutsAnalysis, @@ -49,6 +50,12 @@ RAW_RUNS_TRACE_NAME = "Game Runs" RUNS_Y_AXIS_TITLE = "Runs Scored per Game" +COMPARISON_CHART_DIV_ID = "team-hitting-comparison-chart" +HITS_INDEX_TRACE_NAME = "Hits Index" +STRIKEOUTS_INDEX_TRACE_NAME = "Batting Strikeout Index" +NORMALIZED_BASELINE_TRACE_NAME = "Baseline (100)" +COMPARISON_Y_AXIS_TITLE = "Normalized Index (MLB Avg = 100)" + _NAVY = "#12263f" _TEAL = "#0f8b8d" # Distinct hue *and* distinct dash from the navy team line, so the two @@ -62,6 +69,11 @@ # The right gutter holds the reference-line label; the rest is sized by the # axes themselves. _MARGIN = {"l": 8, "r": 78, "t": 8, "b": 8} +# The comparison has three longer legend entries and no right-edge label. +# Its top band lets the horizontal legend wrap on a phone without covering the +# plot. The small right gutter also keeps the final two-line date label inside +# the SVG at 390px rather than letting Plotly hide that boundary tick. +_COMPARISON_MARGIN = {"l": 8, "r": 32, "t": 76, "b": 8} _AXIS_TITLE_FONT = {"size": 12, "color": _AXIS_INK} _TICK_FONT = {"size": 11, "color": _AXIS_INK} @@ -622,6 +634,123 @@ def build_team_runs_figure( return figure +def build_team_hitting_comparison_figure( + analysis: TeamHittingComparisonAnalysis, +) -> go.Figure: + """Build the normalized hits-versus-batting-strikeouts figure. + + Both solid lines are rolling team rates expressed as an index of their own + MLB-wide rate. The dotted line is the common 100 baseline. Colour separates + the two metrics, but deliberately does not encode good or bad: an index + above 100 only means the team recorded more of that metric than MLB. + """ + game_numbers = [point.season_game_number for point in analysis.points] + game_dates = [point.game_date for point in analysis.points] + hits_indexes = [point.hits_index for point in analysis.points] + strikeout_indexes = [point.strikeouts_index for point in analysis.points] + hover_data = [ + ( + format_long_date(point.game_date), + point.opponent_name, + point.hits_index, + point.strikeouts_index, + ) + for point in analysis.points + ] + hover_template = ( + "%{customdata[0]}
" + "Opponent: %{customdata[1]}
" + "Hits Index: %{customdata[2]:.1f}
" + "Batting Strikeout Index: %{customdata[3]:.1f}" + ) + + figure = go.Figure() + figure.add_trace( + go.Scatter( + x=game_numbers, + y=hits_indexes, + customdata=hover_data, + name=HITS_INDEX_TRACE_NAME, + mode="lines", + # Straight segments connect values calculated at actual games; + # smoothing would imply intermediate indexes never calculated. + line={"color": _TEAL, "width": 3.5, "shape": "linear"}, + hovertemplate=hover_template, + ) + ) + figure.add_trace( + go.Scatter( + x=game_numbers, + y=strikeout_indexes, + customdata=hover_data, + name=STRIKEOUTS_INDEX_TRACE_NAME, + mode="lines", + line={"color": _NAVY, "width": 3.5, "shape": "linear"}, + hovertemplate=hover_template, + ) + ) + baseline = analysis.baseline_index + figure.add_trace( + go.Scatter( + x=[game_numbers[0], game_numbers[-1]], + y=[baseline, baseline], + name=NORMALIZED_BASELINE_TRACE_NAME, + mode="lines", + # The MLB baseline is distinct in hue and line pattern from both + # team metrics, including when the chart is read in greyscale. + line={"color": _AMBER, "width": 2, "dash": "dot"}, + hoverinfo="skip", + ) + ) + tick_values, tick_labels = _season_game_ticks(game_numbers, game_dates) + figure.update_layout( + template="plotly_white", + margin=_COMPARISON_MARGIN, + height=470, + hovermode="closest", + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font={"family": "system-ui, -apple-system, 'Segoe UI', sans-serif", "size": 13}, + legend={ + "orientation": "h", + "yanchor": "bottom", + "y": 1.03, + "xanchor": "center", + "x": 0.5, + "font": {"size": 12, "color": _AXIS_INK}, + }, + xaxis={ + "title": {"text": X_AXIS_TITLE, "standoff": 10, "font": _AXIS_TITLE_FONT}, + "tickfont": _TICK_FONT, + "tickmode": "array", + "tickvals": tick_values, + "ticktext": tick_labels, + "showgrid": False, + "showline": True, + "linecolor": _AXIS_LINE, + "zeroline": False, + "rangemode": "tozero", + "automargin": True, + }, + yaxis={ + "title": { + "text": COMPARISON_Y_AXIS_TITLE, + "standoff": 10, + "font": _AXIS_TITLE_FONT, + }, + "tickfont": _TICK_FONT, + "gridcolor": _GRID, + "griddash": "dot", + "zeroline": False, + # Normalized values usually cluster around 100. Autorange keeps + # their movement legible instead of forcing an unrelated zero. + "tickformat": ".0f", + "automargin": True, + }, + ) + return figure + + def render_figure_html(figure: go.Figure, *, div_id: str = CHART_DIV_ID) -> str: """Render a figure as an embeddable div. diff --git a/app/web/formatting.py b/app/web/formatting.py index d7395e2..502a42c 100644 --- a/app/web/formatting.py +++ b/app/web/formatting.py @@ -6,6 +6,7 @@ from app.schemas.analytics import ( TeamHitsAnalysis, TeamHitsLeagueComparison, + TeamHittingComparisonAnalysis, TeamRunsAnalysis, TeamRunsLeagueComparison, TeamStrikeoutsAnalysis, @@ -16,6 +17,7 @@ HITS_PER_GAME_CAPTION = "Hits per Game" STRIKEOUTS_PER_GAME_CAPTION = "Batting Strikeouts per Game" RUNS_PER_GAME_CAPTION = "Runs Scored per Game" +NORMALIZED_INDEX_CAPTION = "MLB Avg = 100" NO_LEAGUE_COMPARISON_VALUE = "—" NO_LEAGUE_COMPARISON_CAPTION = "Comparison unavailable" LEAGUE_COMPARISON_UNAVAILABLE_NOTE = ( @@ -76,6 +78,50 @@ class SummaryCard: caption: str +def _format_normalized_index(value: float, *, signed: bool = False) -> str: + """Format an index to one decimal, omitting a redundant trailing zero. + + The analytics model keeps full precision. Index cards are larger-scale + glance values, so ``108.0`` reads as ``108`` while a meaningful fractional + point such as ``108.4`` remains visible. A signed zero is a real result for + the gap and stays distinct from the unavailable state, which is rendered by + the route before cards are built. + """ + if signed and abs(value) < 0.05: + value = 0.0 + rendered = f"{value:+.1f}" if signed else f"{value:.1f}" + return rendered.removesuffix(".0") + + +def build_hitting_comparison_summary_cards( + analysis: TeamHittingComparisonAnalysis, +) -> list[SummaryCard]: + """Build the four normalized-comparison cards without judging direction.""" + summary = analysis.summary + return [ + SummaryCard( + label="Recent Hits Index", + value=_format_normalized_index(summary.recent_hits_index), + caption=NORMALIZED_INDEX_CAPTION, + ), + SummaryCard( + label="Recent K Index", + value=_format_normalized_index(summary.recent_strikeouts_index), + caption=NORMALIZED_INDEX_CAPTION, + ), + SummaryCard( + label="Trend Gap", + value=_format_normalized_index(summary.trend_gap, signed=True), + caption="Hits Index − K Index", + ), + SummaryCard( + label="Games Played", + value=str(summary.games_played), + caption="Completed Games", + ), + ] + + def build_summary_cards( analysis: TeamHitsAnalysis, league_comparison: TeamHitsLeagueComparison | None = None, diff --git a/app/web/navigation.py b/app/web/navigation.py index 559aaa8..e1f5158 100644 --- a/app/web/navigation.py +++ b/app/web/navigation.py @@ -1,9 +1,10 @@ -"""Links between the metric pages, keeping the reader's selection intact. +"""Links between the analytics pages, keeping the reader's selection intact. -Moving between hits, batting strikeouts, and runs should not throw away the -team, season, and rolling window the reader chose, so each link carries them -forward. Only selections that are actually set are added, so a page that has no -team yet links to a plain path rather than one with empty parameters. +Moving between hits, batting strikeouts, runs, and their normalized comparison +should not throw away the team, season, and rolling window the reader chose, so +each link carries them forward. Only selections that are actually set are +added, so a page that has no team yet links to a plain path rather than one with +empty parameters. """ from dataclasses import dataclass @@ -12,10 +13,12 @@ HITS_PATH = "/" STRIKEOUTS_PATH = "/strikeouts" RUNS_PATH = "/runs" +COMPARISON_PATH = "/comparison" HITS_LABEL = "Hits" STRIKEOUTS_LABEL = "Batting Strikeouts" RUNS_LABEL = "Runs" +COMPARISON_LABEL = "Comparison" @dataclass(frozen=True) @@ -61,4 +64,9 @@ def build_nav_links( href=f"{RUNS_PATH}{suffix}", is_current=current_path == RUNS_PATH, ), + NavLink( + label=COMPARISON_LABEL, + href=f"{COMPARISON_PATH}{suffix}", + is_current=current_path == COMPARISON_PATH, + ), ] diff --git a/app/web/routes.py b/app/web/routes.py index f524566..f215601 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -29,6 +29,10 @@ supports_league_wide_strikeout_average, ) from app.analytics.team_hitting import DEFAULT_ROLLING_WINDOW, build_team_hits_analysis +from app.analytics.team_hitting_comparison import ( + InvalidComparisonBaselineError, + build_team_hitting_comparison_analysis, +) from app.analytics.team_runs import build_team_runs_analysis from app.analytics.team_strikeouts import ( MissingStrikeoutDataError, @@ -52,9 +56,11 @@ TeamStrikeoutsLeagueComparison, ) from app.web.charts import ( + COMPARISON_CHART_DIV_ID, RUNS_CHART_DIV_ID, STRIKEOUTS_CHART_DIV_ID, build_team_hits_figure, + build_team_hitting_comparison_figure, build_team_runs_figure, build_team_strikeouts_figure, plotly_bundle_javascript, @@ -63,6 +69,7 @@ ) from app.web.dependencies import get_db_session from app.web.formatting import ( + build_hitting_comparison_summary_cards, build_runs_summary_cards, build_strikeout_summary_cards, build_summary_cards, @@ -73,6 +80,7 @@ format_long_date, ) from app.web.navigation import ( + COMPARISON_PATH, HITS_PATH, RUNS_PATH, STRIKEOUTS_PATH, @@ -494,6 +502,190 @@ def runs( request=request, name="runs.html", context=context ) + @router.get(COMPARISON_PATH, response_class=HTMLResponse) + def hitting_comparison( + request: Request, + session: Annotated[Session, Depends(get_db_session)], + team_id: Annotated[ + int | None, + Query(gt=0, description="MLB team id that has been imported locally."), + ] = None, + season: Annotated[ + int | None, + Query(gt=0, description="Season that has been imported for the team."), + ] = None, + window: Annotated[ + RollingWindowParam, + Query(description="Games in the trailing rolling average."), + ] = DEFAULT_ROLLING_WINDOW, + ) -> Response: + """Render normalized rolling Hits/Game and batting K/Game trends.""" + try: + available = list_available_team_seasons(session) + except DatabaseSchemaMissingError as exc: + return _render_schema_error(templates, request, settings, exc) + + teams = build_team_options(available) + context: dict[str, Any] = { + "app_name": settings.app_name, + "teams": teams, + "team_seasons_catalog": build_team_seasons_catalog(teams), + "window_options": ROLLING_WINDOW_OPTIONS, + "selected_window": window, + "selected_team": None, + "selected_season": None, + "import_command": IMPORT_COMMAND, + "plotly_bundle_path": PLOTLY_BUNDLE_PATH, + "mlb_logo_url": MLB_LOGO_URL, + "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX, + "form_action": COMPARISON_PATH, + "nav_links": build_nav_links( + current_path=COMPARISON_PATH, + team_id=team_id, + season=season, + window=window, + ), + } + + if not teams: + context["state"] = "empty" + return templates.TemplateResponse( + request=request, name="comparison.html", context=context + ) + + selected_team = select_team(teams, team_id) + if selected_team is None: + context["state"] = "not_found" + context["not_found_message"] = ( + f"No games are stored for team id {team_id}. " + "Pick a team that has been imported, or import that team." + ) + return templates.TemplateResponse( + request=request, + name="comparison.html", + context=context, + status_code=404, + ) + + context["selected_team"] = selected_team + selected_season = select_season(selected_team, season) + if selected_season is None: + context["state"] = "not_found" + context["not_found_message"] = ( + f"No {season} games are stored for {selected_team.team_name}. " + f"Stored seasons: " + f"{', '.join(str(value) for value in selected_team.seasons)}." + ) + return templates.TemplateResponse( + request=request, + name="comparison.html", + context=context, + status_code=404, + ) + + context["selected_season"] = selected_season + context["nav_links"] = build_nav_links( + current_path=COMPARISON_PATH, + team_id=selected_team.team_id, + season=selected_season, + window=window, + ) + games = list_team_season( + session, team_id=selected_team.team_id, season=selected_season + ) + hits_analysis = build_team_hits_analysis(games, rolling_window=window) + try: + strikeouts_analysis = build_team_strikeouts_analysis( + games, rolling_window=window + ) + except MissingStrikeoutDataError as exc: + return _render_comparison_unavailable( + templates, + request, + context, + message=( + f"{exc.games_missing} of the {exc.games_total} stored games for " + f"{selected_team.team_name} in {selected_season} have no batting " + "strikeout total. Their real values are unknown, so no " + "normalized indexes were calculated. Re-import this " + "team-season to backfill them." + ), + command=import_command_for(selected_team.team_id, selected_season), + ) + + coverage = get_league_season_ingestion(session, season=selected_season) + if not ( + supports_league_wide_average(coverage) + and supports_league_wide_strikeout_average(coverage) + ): + return _render_comparison_unavailable( + templates, + request, + context, + message=( + "The latest league-season import must have COMPLETE coverage " + "before MLB Hits/Game and batting K/Game can be used as " + "baselines. No normalized indexes were calculated." + ), + ) + + league_games = list_league_season(session, season=selected_season) + league_hits = build_league_hits_context(league_games) + try: + league_strikeouts = build_league_strikeouts_context(league_games) + except MissingLeagueStrikeoutDataError as exc: + return _render_comparison_unavailable( + templates, + request, + context, + message=( + f"{exc.records_missing} of the {exc.records_total} team-game " + f"records stored for {exc.season} have no batting strikeout " + "total. Unknown totals are not treated as zero, so no " + "normalized indexes were calculated. Re-import the league " + "season to backfill them." + ), + command=league_import_command_for(selected_season), + ) + + try: + analysis = build_team_hitting_comparison_analysis( + hits_analysis, + strikeouts_analysis, + league_hits, + league_strikeouts, + ) + except InvalidComparisonBaselineError: + return _render_comparison_unavailable( + templates, + request, + context, + message=( + "Normalized indexes require positive MLB Hits/Game and batting " + "K/Game baselines. At least one stored baseline is zero, so no " + "normalized indexes were calculated." + ), + ) + + figure = build_team_hitting_comparison_figure(analysis) + context.update( + { + "state": "ok", + "analysis": analysis, + "chart_html": render_figure_html( + figure, div_id=COMPARISON_CHART_DIV_ID + ), + "summary_cards": build_hitting_comparison_summary_cards(analysis), + "mlb_hits_per_game": f"{analysis.mlb_hits_per_game:.2f}", + "mlb_strikeouts_per_game": (f"{analysis.mlb_strikeouts_per_game:.2f}"), + "league_team_game_records": f"{league_hits.team_game_records:,}", + "data_through": format_long_date(analysis.last_game_date), + } + ) + return templates.TemplateResponse( + request=request, name="comparison.html", context=context + ) + @router.get(PLOTLY_BUNDLE_PATH, include_in_schema=False) def plotly_bundle() -> Response: """Serve the plotly.js bundle from the installed package. @@ -599,6 +791,27 @@ def _load_league_runs_comparison( return compare_team_runs_to_league(analysis, league) +def _render_comparison_unavailable( + templates: Jinja2Templates, + request: Request, + context: dict[str, Any], + *, + message: str, + command: str | None = None, +) -> Response: + """Keep selectors and navigation usable while withholding unsupported values.""" + context.update( + { + "state": "unavailable", + "unavailable_message": message, + "unavailable_command": command, + } + ) + return templates.TemplateResponse( + request=request, name="comparison.html", context=context + ) + + def _render_schema_error( templates: Jinja2Templates, request: Request, diff --git a/app/web/static/css/app.css b/app/web/static/css/app.css index fe9f473..c0430f1 100644 --- a/app/web/static/css/app.css +++ b/app/web/static/css/app.css @@ -92,6 +92,7 @@ body { display: flex; flex-wrap: wrap; gap: 0.35rem; + max-width: 100%; } .site-nav__link { @@ -307,6 +308,13 @@ body { min-width: 27rem; } +/* The normalized chart is intentionally composed for a phone-sized plot. Its + three aggregate traces stay legible without forcing the internal horizontal + scroller used by the denser game-level charts. */ +.chart-card__figure--comparison .plotly-graph-div { + min-width: 0; +} + /* Summary cards */ .summary { @@ -528,6 +536,22 @@ body { grid-template-columns: minmax(0, 1fr); } + /* The comparison values are compact indexes. Keeping them in a two-by-two + grid preserves the reference layout's visual hierarchy on a phone without + making any card too narrow to read. */ + .summary--comparison { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + } + + .summary--comparison .summary-card { + padding: 0.95rem 1rem 1rem; + } + + .summary--comparison .summary-card__label { + min-height: 2.45em; + } + .summary-card__value { font-size: 1.9rem; } @@ -547,4 +571,19 @@ body { .notice { padding: 1.05rem 1.1rem; } + + .chart-card--comparison { + padding-right: 0.85rem; + padding-left: 0.85rem; + } +} + +@media (max-width: 22rem) { + .summary--comparison { + grid-template-columns: minmax(0, 1fr); + } + + .summary--comparison .summary-card__label { + min-height: 0; + } } diff --git a/app/web/templates/comparison.html b/app/web/templates/comparison.html new file mode 100644 index 0000000..71ec379 --- /dev/null +++ b/app/web/templates/comparison.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} + +{% block title %} + {%- if state == "ok" -%} + {{ analysis.team_name }} {{ analysis.season }} Hitting Trends Comparison + {%- else -%} + Team Hitting Trends Comparison + {%- endif -%} +{% endblock %} + +{% block head %} + {# plotly.js must be parsed before the figure div's inline bootstrap script. #} + {% if state == "ok" %} + + {% endif %} + {% if state != "empty" %} + + {% endif %} +{% endblock %} + +{% block content %} +
+

Team Hitting Trends Comparison

+

+ Compare rolling hits and batting strikeouts on the same MLB-average baseline. +

+
+ + {% if state == "empty" %} +
+

No team data has been imported yet

+

Import a team-season, then reload this page:

+
{{ import_command }}
+
+ {% else %} + {% include "_selector_form.html" %} + + {% if state == "not_found" %} +
+

That team-season is not stored locally

+

{{ not_found_message }}

+

Import it with:

+
{{ import_command }}
+
+ {% elif state == "unavailable" %} +
+

Normalized comparison unavailable

+

{{ unavailable_message }}

+ {% if unavailable_command %} +
{{ unavailable_command }}
+ {% endif %} +

+ The individual Hits, Batting Strikeouts, and Runs pages remain available + for the selected team-season. +

+
+ {% else %} +
+
+

{{ analysis.team_name }} — Hits vs Batting Strikeouts

+

{{ analysis.season }} regular season

+
+
+ {{ chart_html | safe }} +
+
+ +
+ {% for card in summary_cards %} +
+

{{ card.label }}

+

{{ card.value }}

+

{{ card.caption }}

+
+ {% endfor %} +
+ +
+ +
+

About this chart

+

+ Each point uses the trailing {{ analysis.rolling_window }}-game team + average ending at that game. Hits Index is rolling + team Hits/Game divided by MLB Hits/Game, times 100. + Batting Strikeout Index applies the same calculation + to batting K/Game. Early-season points use every completed game + available so far. +

+

+ The differentiated baseline is 100, meaning MLB average for that + metric. Above 100 means above the MLB average, not automatically + better: for batting strikeouts it specifically means the team's + hitters struck out more times per game than MLB hitters. +

+

+ The MLB baselines are {{ mlb_hits_per_game }} Hits/Game and + {{ mlb_strikeouts_per_game }} batting K/Game across the + {{ league_team_game_records }} team-game records currently stored for + {{ analysis.season }}. They are shown only because the latest + league-season import recorded complete coverage and every stored + strikeout total is known. +

+

+ Trend Gap is simply the recent Hits Index minus the + recent K Index. It is not a validated overall offensive-performance + statistic. This comparison is descriptive only: it is not K%, a + ranking, a percentile, a significance test, or a causal claim. +

+
+
+ {% endif %} + {% endif %} +{% endblock %} + +{% block footer %} + {% if state == "ok" %} + Data through {{ data_through }} + {% endif %} + Source: MLB Stats API via python-mlb-statsapi +{% endblock %} diff --git a/docs/team-hitting-trends-comparison.md b/docs/team-hitting-trends-comparison.md new file mode 100644 index 0000000..947fd93 --- /dev/null +++ b/docs/team-hitting-trends-comparison.md @@ -0,0 +1,170 @@ +# Team hitting trends comparison + +Issue #25 adds a dedicated comparison page for one descriptive question: + +> How are a team's rolling Hits/Game and batting Strikeouts/Game moving relative +> to the corresponding MLB per-game averages? + +The two raw statistics have different units and typical values, so the page does +not plot them together directly. Each is converted to its own normalized index, +where that metric's MLB average is 100. + +## Formulas + +For every rolling point: + +```text +Hits Index = rolling team Hits/Game / MLB Hits/Game * 100 + +Batting Strikeout Index = rolling team batting K/Game + / MLB batting K/Game + * 100 +``` + +The rolling team values come from the existing Hits and Batting Strikeouts +analyses. The window is trailing and includes the current game. Before a full +window exists, it uses every completed game available so far. + +The MLB baselines are game-weighted averages over persisted team-game records: + +```text +MLB Hits/Game = total league hits / league team-game records + +MLB batting K/Game = total league batting strikeouts + / league team-game records +``` + +Full floating-point precision is preserved through the analytics layer. Plotly +hover labels and summary cards round only for presentation. + +## Interpretation + +- `100` is MLB average for the named metric. +- A Hits Index of `108` means the rolling team Hits/Game value is 108% of MLB + Hits/Game. +- A Batting Strikeout Index of `94` means the rolling team batting K/Game value + is 94% of MLB batting K/Game. +- Above 100 is not automatically good. For batting strikeouts, above 100 means + the team's hitters struck out more times per game than MLB hitters. + +The page applies no positive or negative colour semantics to either series or +to the gap between them. + +## Trend Gap + +The summary card is calculated as: + +```text +Trend Gap = recent Hits Index - recent Batting Strikeout Index +``` + +It is simply a difference between two normalized indexes. It is not a +validated overall offensive-performance statistic, a ranking, a percentile, or +a significance test. It makes no causal or predictive claim. + +## Coverage and data integrity + +Both MLB baselines must be trustworthy before any normalized point is +calculated. + +The route reuses the existing league rules: + +1. The latest persisted league-season ingestion state must be `COMPLETE`. +2. Completeness is never inferred from row, team, or game counts. +3. Every persisted league team-game record for the season must carry a known + batting strikeout total. +4. Both MLB per-game baselines must be greater than zero. + +`COMPLETE` describes the league refresh, not whether the season has finished. +An in-progress season can qualify when every discovered club was refreshed, and +the averages then describe the completed games currently stored. + +If coverage is `INCOMPLETE`, `RUNNING`, or absent, the page keeps its selectors +and navigation but shows no chart or normalized value. The same unavailable +state is used when a baseline is zero. No fallback value is fabricated. + +If any stored league record has `strikeouts IS NULL`, the known subset is not +averaged and called MLB-wide. The page instead explains that the league season +must be re-imported. If the selected team's own games contain an unknown +strikeout total, it gives the corresponding team-season re-import command. + +## Architecture + +```text +GET /comparison?team_id=136&season=2025&window=15 + │ + ├─ list_available_team_seasons(...) database only + ├─ list_team_season(...) + ├─ build_team_hits_analysis(...) + ├─ build_team_strikeouts_analysis(...) + │ + ├─ get_league_season_ingestion(...) + ├─ existing COMPLETE coverage checks + ├─ list_league_season(...) + ├─ build_league_hits_context(...) + ├─ build_league_strikeouts_context(...) + │ + ├─ build_team_hitting_comparison_analysis(...) + ├─ build_team_hitting_comparison_figure(...) + └─ comparison.html +``` + +Layer ownership remains explicit: + +- `app/database/repositories.py` returns persisted domain records and coverage + state; it performs no baseball calculation. +- `app/analytics/team_hitting_comparison.py` validates aligned typed inputs and + calculates the normalized points and Trend Gap; it imports no SQLAlchemy, + FastAPI, Jinja, Plotly, or MLB client. +- `app/web/charts.py` constructs the three Plotly traces. +- `app/web/formatting.py` rounds the four summary cards for display. +- `app/web/routes.py` wires persisted inputs to analytics and renders expected + unavailable states. +- `app/web/templates/comparison.html` owns the server-rendered layout and + explanatory wording. + +Normal browser requests remain database-only. League and team imports continue +to be explicit CLI operations. + +## Route and presentation + +The route is: + +```text +/comparison?team_id=136&season=2025&window=15 +``` + +It supports the same `team_id`, `season`, and `window` query parameters and the +same `5`, `10`, `15`, and `30` game windows as the existing pages. Navigation +preserves all three values. + +The chart contains exactly: + +1. `Hits Index` +2. `Batting Strikeout Index` +3. `Baseline (100)` + +The x axis is `Season Game Number`; the y axis is +`Normalized Index (MLB Avg = 100)`. The baseline uses a distinct hue and dotted +line pattern, and the chart uses straight segments rather than spline +smoothing. + +The four cards are: + +1. `Recent Hits Index` +2. `Recent K Index` +3. `Trend Gap` +4. `Games Played` + +The page adds no placeholder controls from the visual reference. There are no +7D/30D/60D buttons, overflow menu, export control, or Players control. + +## Limitations + +- Batting K/Game is a per-game count, not K%. Games can contain different + numbers of plate appearances. +- The indexes are not adjusted for park, opponent, game length, or opportunity. +- The comparison is descriptive only. It includes no regression, significance + testing, rankings, percentiles, or causal claims. +- Results describe the completed team and league games currently stored, not a + guaranteed finished season. diff --git a/tests/test_analytics_team_hitting_comparison.py b/tests/test_analytics_team_hitting_comparison.py new file mode 100644 index 0000000..21e397a --- /dev/null +++ b/tests/test_analytics_team_hitting_comparison.py @@ -0,0 +1,228 @@ +"""Tests for normalized rolling hits-versus-batting-strikeouts analytics.""" + +from datetime import date + +import pytest + +from app.analytics.team_hitting import build_team_hits_analysis +from app.analytics.team_hitting_comparison import ( + InvalidComparisonBaselineError, + TeamHittingComparisonError, + build_team_hitting_comparison_analysis, +) +from app.analytics.team_strikeouts import build_team_strikeouts_analysis +from app.schemas.analytics import LeagueHitsContext, LeagueStrikeoutsContext +from tests.factories import ( + make_batting_line, + make_league_hits_context, + make_league_strikeouts_context, + make_season, +) + + +def comparison_for( + hits: list[int], + strikeouts: list[int], + *, + window: int = 2, + mlb_hits_per_game: int = 8, + mlb_strikeouts_per_game: int = 10, +): + games = make_season(hits, strikeouts=strikeouts) + return build_team_hitting_comparison_analysis( + build_team_hits_analysis(games, rolling_window=window), + build_team_strikeouts_analysis(games, rolling_window=window), + make_league_hits_context( + total_hits=mlb_hits_per_game * 10, + team_game_records=10, + ), + make_league_strikeouts_context( + total_strikeouts=mlb_strikeouts_per_game * 10, + team_game_records=10, + ), + ) + + +def test_hits_index_normalizes_each_rolling_average_to_mlb_hits_per_game() -> None: + analysis = comparison_for([8, 16, 8], [10, 5, 15]) + + # Rolling Hits/Game is [8, 12, 12], divided by the 8.0 MLB baseline. + assert [point.hits_index for point in analysis.points] == pytest.approx( + [100.0, 150.0, 150.0] + ) + + +def test_strikeout_index_normalizes_each_rolling_average_to_mlb_k_per_game() -> None: + analysis = comparison_for([8, 16, 8], [10, 5, 15]) + + # Rolling batting K/Game is [10, 7.5, 10], divided by the 10.0 MLB baseline. + assert [point.strikeouts_index for point in analysis.points] == pytest.approx( + [100.0, 75.0, 100.0] + ) + + +def test_a_rolling_value_equal_to_its_mlb_average_has_index_100() -> None: + analysis = comparison_for([8, 8, 8], [10, 10, 10], window=3) + + assert analysis.baseline_index == 100.0 + assert all(point.hits_index == 100.0 for point in analysis.points) + assert all(point.strikeouts_index == 100.0 for point in analysis.points) + + +def test_selected_rolling_window_is_preserved_and_used_for_both_indexes() -> None: + analysis = comparison_for( + [2, 4, 12, 20], + [20, 10, 5, 1], + window=3, + mlb_hits_per_game=4, + mlb_strikeouts_per_game=5, + ) + + assert analysis.rolling_window == 3 + # Game 4 uses games 2-4: Hits/Game = 12; batting K/Game = 16 / 3. + assert analysis.points[-1].hits_index == pytest.approx(300.0) + assert analysis.points[-1].strikeouts_index == pytest.approx((16 / 3) / 5 * 100) + + +def test_summary_uses_the_recent_indexes_and_their_descriptive_gap() -> None: + analysis = comparison_for([8, 16, 8], [10, 5, 15]) + + assert analysis.summary.games_played == 3 + assert analysis.summary.recent_hits_index == pytest.approx(150.0) + assert analysis.summary.recent_strikeouts_index == pytest.approx(100.0) + assert analysis.summary.trend_gap == pytest.approx(50.0) + + +def test_negative_trend_gap_is_kept_without_directional_judgment() -> None: + analysis = comparison_for([4, 4], [15, 15], window=2) + + assert analysis.summary.recent_hits_index == pytest.approx(50.0) + assert analysis.summary.recent_strikeouts_index == pytest.approx(150.0) + assert analysis.summary.trend_gap == pytest.approx(-100.0) + + +def test_zero_mlb_hits_baseline_is_rejected_before_division() -> None: + games = make_season([8, 9], strikeouts=[10, 9]) + hits = build_team_hits_analysis(games, rolling_window=2) + strikeouts = build_team_strikeouts_analysis(games, rolling_window=2) + zero_hits = make_league_hits_context(total_hits=0, team_game_records=10) + + with pytest.raises(InvalidComparisonBaselineError, match="Hits/Game") as error: + build_team_hitting_comparison_analysis( + hits, + strikeouts, + zero_hits, + make_league_strikeouts_context(), + ) + + assert error.value.metric == "Hits/Game" + assert error.value.value == 0.0 + + +def test_zero_mlb_strikeout_baseline_is_rejected_before_division() -> None: + games = make_season([8, 9], strikeouts=[10, 9]) + hits = build_team_hits_analysis(games, rolling_window=2) + strikeouts = build_team_strikeouts_analysis(games, rolling_window=2) + zero_strikeouts = make_league_strikeouts_context( + total_strikeouts=0, + team_game_records=10, + ) + + with pytest.raises(InvalidComparisonBaselineError, match="batting K/Game") as error: + build_team_hitting_comparison_analysis( + hits, + strikeouts, + make_league_hits_context(), + zero_strikeouts, + ) + + assert error.value.metric == "batting K/Game" + assert error.value.value == 0.0 + + +def test_team_analyses_must_use_the_same_rolling_window() -> None: + games = make_season([8, 9], strikeouts=[10, 9]) + + with pytest.raises(TeamHittingComparisonError, match="rolling window"): + build_team_hitting_comparison_analysis( + build_team_hits_analysis(games, rolling_window=2), + build_team_strikeouts_analysis(games, rolling_window=1), + make_league_hits_context(), + make_league_strikeouts_context(), + ) + + +def test_team_analyses_must_contain_the_same_ordered_games() -> None: + hits_games = make_season([8, 9], strikeouts=[10, 9]) + strikeout_games = [ + make_batting_line( + game_pk=999001, + game_date=date(2025, 3, 27), + hits=8, + strikeouts=10, + ), + hits_games[1], + ] + + with pytest.raises(TeamHittingComparisonError, match="same games"): + build_team_hitting_comparison_analysis( + build_team_hits_analysis(hits_games, rolling_window=2), + build_team_strikeouts_analysis(strikeout_games, rolling_window=2), + make_league_hits_context(), + make_league_strikeouts_context(), + ) + + +def test_team_analyses_must_contain_the_same_number_of_games() -> None: + games = make_season([8, 9], strikeouts=[10, 9]) + + with pytest.raises(TeamHittingComparisonError, match="same games"): + build_team_hitting_comparison_analysis( + build_team_hits_analysis(games, rolling_window=2), + build_team_strikeouts_analysis(games[:1], rolling_window=2), + make_league_hits_context(), + make_league_strikeouts_context(), + ) + + +def test_both_league_contexts_must_match_the_team_season() -> None: + games = make_season([8, 9], strikeouts=[10, 9]) + + with pytest.raises(TeamHittingComparisonError, match="same season"): + build_team_hitting_comparison_analysis( + build_team_hits_analysis(games, rolling_window=2), + build_team_strikeouts_analysis(games, rolling_window=2), + make_league_hits_context(), + LeagueStrikeoutsContext( + season=2024, + teams_represented=2, + team_game_records=10, + total_strikeouts=80, + strikeouts_per_game=8.0, + ), + ) + + +def test_indexes_keep_calculation_precision_for_presentation_to_round() -> None: + games = make_season([1, 2, 2], strikeouts=[2, 1, 1]) + analysis = build_team_hitting_comparison_analysis( + build_team_hits_analysis(games, rolling_window=3), + build_team_strikeouts_analysis(games, rolling_window=3), + LeagueHitsContext( + season=2025, + teams_represented=2, + team_game_records=3, + total_hits=4, + hits_per_game=4 / 3, + ), + LeagueStrikeoutsContext( + season=2025, + teams_represented=2, + team_game_records=3, + total_strikeouts=5, + strikeouts_per_game=5 / 3, + ), + ) + + assert analysis.points[-1].hits_index == pytest.approx(125.0) + assert analysis.points[-1].strikeouts_index == pytest.approx(80.0) diff --git a/tests/test_charts_comparison.py b/tests/test_charts_comparison.py new file mode 100644 index 0000000..a2bbf7d --- /dev/null +++ b/tests/test_charts_comparison.py @@ -0,0 +1,154 @@ +"""Tests for the normalized hits-versus-strikeouts Plotly contract.""" + +from datetime import date, timedelta + +import pytest + +from app.schemas.analytics import ( + TeamHittingComparisonAnalysis, + TeamHittingComparisonPoint, + TeamHittingComparisonSummary, +) +from app.web.charts import ( + COMPARISON_CHART_DIV_ID, + COMPARISON_Y_AXIS_TITLE, + HITS_INDEX_TRACE_NAME, + NORMALIZED_BASELINE_TRACE_NAME, + STRIKEOUTS_INDEX_TRACE_NAME, + X_AXIS_TITLE, + build_team_hitting_comparison_figure, + render_figure_html, +) + + +@pytest.fixture +def analysis() -> TeamHittingComparisonAnalysis: + hits_indexes = [100.0, 112.5, 125.0, 116.67, 112.5] + strikeouts_indexes = [100.0, 90.0, 80.0, 86.67, 90.0] + opening_day = date(2025, 3, 27) + points = tuple( + TeamHittingComparisonPoint( + game_pk=2025000 + index, + season_game_number=index, + game_date=opening_day + timedelta(days=index - 1), + opponent_name="Minnesota Twins", + hits_index=hits_index, + strikeouts_index=strikeouts_index, + ) + for index, (hits_index, strikeouts_index) in enumerate( + zip(hits_indexes, strikeouts_indexes, strict=True), start=1 + ) + ) + return TeamHittingComparisonAnalysis( + team_id=136, + team_name="Seattle Mariners", + season=2025, + rolling_window=3, + mlb_hits_per_game=8.0, + mlb_strikeouts_per_game=10.0, + baseline_index=100.0, + points=points, + summary=TeamHittingComparisonSummary( + games_played=5, + recent_hits_index=112.5, + recent_strikeouts_index=90.0, + trend_gap=22.5, + ), + ) + + +@pytest.fixture +def figure(analysis: TeamHittingComparisonAnalysis): + return build_team_hitting_comparison_figure(analysis) + + +def test_figure_has_the_three_required_traces_in_order(figure) -> None: + assert [trace.name for trace in figure.data] == [ + HITS_INDEX_TRACE_NAME, + STRIKEOUTS_INDEX_TRACE_NAME, + NORMALIZED_BASELINE_TRACE_NAME, + ] + + +def test_metric_traces_plot_the_calculated_indexes(figure) -> None: + assert list(figure.data[0].y) == pytest.approx([100.0, 112.5, 125.0, 116.67, 112.5]) + assert list(figure.data[1].y) == pytest.approx([100.0, 90.0, 80.0, 86.67, 90.0]) + + +def test_metric_traces_use_the_season_game_number(figure) -> None: + assert list(figure.data[0].x) == [1, 2, 3, 4, 5] + assert list(figure.data[1].x) == [1, 2, 3, 4, 5] + + +def test_baseline_is_a_flat_100_line_across_the_stored_games(figure) -> None: + baseline = figure.data[2] + assert list(baseline.x) == [1, 5] + assert list(baseline.y) == pytest.approx([100.0, 100.0]) + assert baseline.hoverinfo == "skip" + + +def test_baseline_is_visually_distinct_from_both_metric_traces(figure) -> None: + hits, strikeouts, baseline = figure.data + assert baseline.line.dash == "dot" + assert hits.line.dash != baseline.line.dash + assert strikeouts.line.dash != baseline.line.dash + assert baseline.line.color not in {hits.line.color, strikeouts.line.color} + + +def test_metric_lines_are_distinct_and_do_not_use_spline_smoothing(figure) -> None: + hits, strikeouts = figure.data[:2] + assert hits.line.color != strikeouts.line.color + assert hits.line.shape == "linear" + assert strikeouts.line.shape == "linear" + assert hits.line.smoothing is None + assert strikeouts.line.smoothing is None + + +def test_axis_titles_explain_the_index_baseline(figure) -> None: + assert figure.layout.xaxis.title.text == X_AXIS_TITLE + assert figure.layout.yaxis.title.text == COMPARISON_Y_AXIS_TITLE + assert figure.layout.yaxis.title.text == "Normalized Index (MLB Avg = 100)" + + +def test_normalized_axis_does_not_force_zero_or_a_hardcoded_range(figure) -> None: + assert figure.layout.yaxis.rangemode is None + assert figure.layout.yaxis.range is None + + +def test_x_axis_ticks_include_the_game_date(figure) -> None: + assert figure.layout.xaxis.tickvals[0] == 1 + assert figure.layout.xaxis.ticktext[0] == "1
Mar 27" + assert figure.layout.xaxis.tickvals[-1] == 5 + + +def test_hover_names_both_indexes_without_directional_judgment(figure) -> None: + template = figure.data[0].hovertemplate + assert "Hits Index: %{customdata[2]:.1f}" in template + assert "Batting Strikeout Index: %{customdata[3]:.1f}" in template + assert "good" not in template.lower() + assert "bad" not in template.lower() + assert figure.data[1].hovertemplate == template + + +def test_hover_data_carries_the_date_and_opponent(figure) -> None: + first = figure.data[0].customdata[0] + assert first[0] == "March 27, 2025" + assert first[1] == "Minnesota Twins" + + +def test_comparison_layout_leaves_mobile_room_for_legend_and_last_tick(figure) -> None: + assert figure.layout.annotations in (None, ()) + assert figure.layout.margin.r >= 30 + assert figure.layout.margin.t >= 70 + + +def test_chart_heading_remains_a_server_rendered_concern(figure) -> None: + """The existing pages put their chart heading in Jinja, outside Plotly.""" + assert figure.layout.title.text is None + + +def test_rendered_html_uses_the_comparison_div_id(figure) -> None: + html = render_figure_html(figure, div_id=COMPARISON_CHART_DIV_ID) + assert f'id="{COMPARISON_CHART_DIV_ID}"' in html + assert "Plotly.newPlot" in html + assert "plotly.js" not in html.lower() diff --git a/tests/test_formatting.py b/tests/test_formatting.py index be90fc3..d6ca10e 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -8,12 +8,16 @@ from app.analytics.league_runs import compare_team_runs_to_league from app.analytics.league_strikeouts import compare_team_strikeouts_to_league from app.analytics.team_hitting import build_team_hits_analysis +from app.analytics.team_hitting_comparison import ( + build_team_hitting_comparison_analysis, +) from app.analytics.team_runs import build_team_runs_analysis from app.analytics.team_strikeouts import build_team_strikeouts_analysis from app.web.formatting import ( LEAGUE_COMPARISON_UNAVAILABLE_NOTE, LEAGUE_RUNS_UNAVAILABLE_NOTE, LEAGUE_STRIKEOUTS_UNAVAILABLE_NOTE, + build_hitting_comparison_summary_cards, build_runs_summary_cards, build_strikeout_summary_cards, build_summary_cards, @@ -270,6 +274,47 @@ def test_short_date_drops_the_year_for_axis_ticks() -> None: assert format_short_date(date(2025, 9, 28)) == "Sep 28" +def test_normalized_comparison_cards_use_the_requested_labels_and_values() -> None: + games = make_season( + hits=[8] * 5, + strikeouts=[9] * 5, + ) + hits = build_team_hits_analysis(games, rolling_window=5) + strikeouts = build_team_strikeouts_analysis(games, rolling_window=5) + analysis = build_team_hitting_comparison_analysis( + hits, + strikeouts, + make_league_hits_context(total_hits=800, team_game_records=100), + make_league_strikeouts_context(total_strikeouts=1000, team_game_records=100), + ) + + cards = build_hitting_comparison_summary_cards(analysis) + assert [card.label for card in cards] == [ + "Recent Hits Index", + "Recent K Index", + "Trend Gap", + "Games Played", + ] + assert [card.value for card in cards] == ["100", "90", "+10", "5"] + assert cards[0].caption == "MLB Avg = 100" + assert cards[1].caption == "MLB Avg = 100" + assert cards[2].caption == "Hits Index − K Index" + assert cards[3].caption == "Completed Games" + + +def test_normalized_comparison_cards_keep_one_meaningful_decimal() -> None: + games = make_season(hits=[9] * 5, strikeouts=[9] * 5) + analysis = build_team_hitting_comparison_analysis( + build_team_hits_analysis(games, rolling_window=5), + build_team_strikeouts_analysis(games, rolling_window=5), + make_league_hits_context(total_hits=800, team_game_records=100), + make_league_strikeouts_context(total_strikeouts=1000, team_game_records=100), + ) + + cards = build_hitting_comparison_summary_cards(analysis) + assert [card.value for card in cards[:3]] == ["112.5", "90", "+22.5"] + + def runs_comparison(runs: list[int], *, window: int, mlb_runs_per_game: float): """Build a team runs analysis and an MLB comparison against a chosen average.""" analysis = build_team_runs_analysis( diff --git a/tests/test_navigation.py b/tests/test_navigation.py index c60e140..5a3c58b 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -1,11 +1,12 @@ -"""Tests for navigation between the metric pages. +"""Tests for navigation between the analytics pages. -Issue #24 added a third entry. The list is asserted in full rather than by +Issue #25 added a fourth entry. The list is asserted in full rather than by membership, so a page added without a route, or a route added without a link, fails here. """ from app.web.navigation import ( + COMPARISON_PATH, HITS_PATH, RUNS_PATH, STRIKEOUTS_PATH, @@ -15,26 +16,41 @@ def test_every_metric_page_is_linked() -> None: links = build_nav_links(current_path=HITS_PATH) - assert [link.label for link in links] == ["Hits", "Batting Strikeouts", "Runs"] + assert [link.label for link in links] == [ + "Hits", + "Batting Strikeouts", + "Runs", + "Comparison", + ] def test_links_point_at_real_routes() -> None: links = build_nav_links(current_path=HITS_PATH) - assert [link.href for link in links] == ["/", "/strikeouts", "/runs"] + assert [link.href for link in links] == [ + "/", + "/strikeouts", + "/runs", + "/comparison", + ] def test_the_current_page_is_marked() -> None: links = build_nav_links(current_path=STRIKEOUTS_PATH) - assert [link.is_current for link in links] == [False, True, False] + assert [link.is_current for link in links] == [False, True, False, False] def test_the_runs_page_can_be_the_current_one() -> None: links = build_nav_links(current_path=RUNS_PATH) - assert [link.is_current for link in links] == [False, False, True] + assert [link.is_current for link in links] == [False, False, True, False] + + +def test_the_comparison_page_can_be_the_current_one() -> None: + links = build_nav_links(current_path=COMPARISON_PATH) + assert [link.is_current for link in links] == [False, False, False, True] def test_only_one_page_is_current_at_a_time() -> None: - for path in (HITS_PATH, STRIKEOUTS_PATH, RUNS_PATH): + for path in (HITS_PATH, STRIKEOUTS_PATH, RUNS_PATH, COMPARISON_PATH): links = build_nav_links(current_path=path) assert sum(link.is_current for link in links) == 1 @@ -43,14 +59,21 @@ def test_selection_is_carried_between_pages() -> None: links = build_nav_links(current_path=HITS_PATH, team_id=136, season=2025, window=15) assert links[1].href == "/strikeouts?team_id=136&season=2025&window=15" assert links[2].href == "/runs?team_id=136&season=2025&window=15" + assert links[3].href == "/comparison?team_id=136&season=2025&window=15" def test_no_selection_produces_plain_paths() -> None: links = build_nav_links(current_path=HITS_PATH) - assert [link.href for link in links] == ["/", "/strikeouts", "/runs"] + assert [link.href for link in links] == [ + "/", + "/strikeouts", + "/runs", + "/comparison", + ] def test_unset_values_are_left_out_of_the_query() -> None: links = build_nav_links(current_path=HITS_PATH, team_id=136, window=30) assert links[1].href == "/strikeouts?team_id=136&window=30" assert links[2].href == "/runs?team_id=136&window=30" + assert links[3].href == "/comparison?team_id=136&window=30" diff --git a/tests/test_web_comparison.py b/tests/test_web_comparison.py new file mode 100644 index 0000000..1a5fcc7 --- /dev/null +++ b/tests/test_web_comparison.py @@ -0,0 +1,606 @@ +"""Offline HTTP tests for the normalized hitting-trends comparison page. + +The comparison is allowed only when the persisted league-season coverage is +COMPLETE, every stored batting strikeout total is known, and both MLB per-game +baselines are non-zero. These tests seed the database directly so no browser +request has any reason to reach the MLB Stats API. +""" + +import html +import json +import re +from collections.abc import Callable, Generator, Iterator +from datetime import datetime +from pathlib import Path +from typing import Any + +import pytest +import requests +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.database.engine import build_engine, build_session_factory +from app.database.repositories import ( + record_league_season_ingestion_finish, + record_league_season_ingestion_start, + upsert_team_season, +) +from app.main import create_app +from app.web.dependencies import get_db_session +from tests.factories import ( + MARINERS_ID, + MARINERS_NAME, + TWINS_ID, + TWINS_NAME, + make_season, +) + +COMPARISON_PATH = "/comparison" +COMPARISON_CHART_DIV_ID = "team-hitting-comparison-chart" +HITS_INDEX_TRACE_NAME = "Hits Index" +STRIKEOUTS_INDEX_TRACE_NAME = "Batting Strikeout Index" +BASELINE_TRACE_NAME = "Baseline (100)" + +STARTED = datetime(2026, 3, 1, 12, 0, 0) +FINISHED = datetime(2026, 3, 1, 12, 30, 0) + +SeedFn = Callable[..., None] +CoverageFn = Callable[..., None] + +_SUMMARY_CARD_PATTERN = re.compile( + r']*class="[^"]*\bsummary-card\b[^"]*"[^>]*>(.*?)', + re.DOTALL, +) + + +@pytest.fixture +def session_factory( + migrated_db_path: Path, +) -> Generator[Callable[[], Session], None, None]: + engine = build_engine(f"sqlite:///{migrated_db_path}") + factory = build_session_factory(engine) + try: + yield factory + finally: + engine.dispose() + + +@pytest.fixture +def seed(session_factory: Callable[[], Session]) -> SeedFn: + """Persist a team-season whose batting fields are chosen by the test.""" + + def _seed( + hits: list[int], + *, + strikeouts: list[int | None], + team_id: int = MARINERS_ID, + team_name: str = MARINERS_NAME, + season: int = 2025, + ) -> None: + # Keep fixture game ids distinct across clubs. Real opponents normally + # share a game_pk, but these compact league fixtures are not schedules. + lines = [ + line.model_copy(update={"game_pk": line.game_pk + team_id * 100_000}) + for line in make_season( + hits, + strikeouts=strikeouts, + team_id=team_id, + team_name=team_name, + season=season, + ) + ] + session = session_factory() + try: + upsert_team_season(session, lines=lines) + session.commit() + finally: + session.close() + + return _seed + + +@pytest.fixture +def record_coverage(session_factory: Callable[[], Session]) -> CoverageFn: + """Record COMPLETE, INCOMPLETE, or RUNNING league-season coverage.""" + + def _record( + *, + season: int = 2025, + teams: int = 2, + failed: int = 0, + finished: bool = True, + ) -> None: + session = session_factory() + try: + with session.begin(): + record_league_season_ingestion_start( + session, + season=season, + expected_team_count=teams, + started_at=STARTED, + ) + if not finished: + return + with session.begin(): + record_league_season_ingestion_finish( + session, + season=season, + expected_team_count=teams, + successful_team_count=teams - failed, + failed_team_count=failed, + started_at=STARTED, + completed_at=FINISHED, + ) + finally: + session.close() + + return _record + + +@pytest.fixture +def client( + session_factory: Callable[[], Session], +) -> Generator[TestClient, None, None]: + app = create_app() + + def override_session() -> Iterator[Session]: + session = session_factory() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db_session] = override_session + with TestClient(app) as test_client: + yield test_client + + +def seed_exact_comparison(seed: SeedFn, record_coverage: CoverageFn) -> None: + """Seed MLB baselines of exactly 8 Hits/Game and 10 batting K/Game. + + Seattle's five-game totals are 56 hits and 40 strikeouts. Minnesota adds + 24 hits and 60 strikeouts, so the ten persisted team-game records total 80 + hits and 100 strikeouts. The deliberately small row count also demonstrates + that COMPLETE coverage, rather than an inferred full-season row count, is + what authorizes the comparison. + """ + + seed([8, 16, 8, 16, 8], strikeouts=[10, 5, 10, 5, 10]) + seed( + [4, 5, 5, 5, 5], + strikeouts=[12, 12, 12, 12, 12], + team_id=TWINS_ID, + team_name=TWINS_NAME, + ) + record_coverage(teams=2) + + +def comparison_response( + client: TestClient, + *, + team_id: int = MARINERS_ID, + season: int = 2025, + window: int = 5, +): + return client.get( + f"{COMPARISON_PATH}?team_id={team_id}&season={season}&window={window}" + ) + + +def visible_text(markup: str) -> str: + """Return collapsed visible text for prose assertions.""" + + without_tags = re.sub(r"<[^>]+>", " ", markup) + return re.sub(r"\s+", " ", html.unescape(without_tags)).strip() + + +def plotly_traces(body: str) -> list[dict[str, Any]]: + """Decode the data argument from the page's Plotly.newPlot call.""" + + marker = "Plotly.newPlot(" + call_start = body.index(marker) + len(marker) + payload = body[call_start:].lstrip() + decoder = json.JSONDecoder() + + div_id, offset = decoder.raw_decode(payload) + assert div_id == COMPARISON_CHART_DIV_ID + payload = payload[offset:].lstrip() + assert payload.startswith(",") + + traces, _ = decoder.raw_decode(payload[1:].lstrip()) + assert isinstance(traces, list) + return traces + + +def summary_card_values(body: str) -> dict[str, str]: + """Map each rendered summary-card label to its displayed value.""" + + cards: dict[str, str] = {} + for block in _SUMMARY_CARD_PATTERN.findall(body): + label_match = re.search( + r']*class="summary-card__label"[^>]*>(.*?)

', + block, + re.DOTALL, + ) + value_match = re.search( + r']*class="summary-card__value"[^>]*>(.*?)

', + block, + re.DOTALL, + ) + assert label_match is not None + assert value_match is not None + cards[visible_text(label_match.group(1))] = visible_text(value_match.group(1)) + return cards + + +def displayed_number(value: str) -> float: + """Parse a signed, presentation-formatted summary-card number.""" + + return float(value.replace(",", "").replace("+", "").replace("\N{MINUS SIGN}", "-")) + + +def assert_comparison_unavailable(response) -> None: + """Assert the shared functional state used when honest indexes are impossible.""" + + assert response.status_code == 200 + body = response.text + assert "Normalized comparison unavailable" in body + assert COMPARISON_CHART_DIV_ID not in body + assert "Plotly.newPlot" not in body + for label in ( + "Recent Hits Index", + "Recent K Index", + "Trend Gap", + "Games Played", + ): + assert label not in body + + +# --- COMPLETE coverage: exact analytics and rendered contract ----------------- + + +def test_complete_coverage_renders_exact_rolling_indexes_and_four_summary_cards( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_exact_comparison(seed, record_coverage) + + response = comparison_response(client) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + body = response.text + assert "Seattle Mariners — Hits vs Batting Strikeouts" in body + + traces = plotly_traces(body) + assert [trace["name"] for trace in traces] == [ + HITS_INDEX_TRACE_NAME, + STRIKEOUTS_INDEX_TRACE_NAME, + BASELINE_TRACE_NAME, + ] + assert traces[0]["x"] == [1, 2, 3, 4, 5] + assert traces[1]["x"] == [1, 2, 3, 4, 5] + assert traces[0]["y"] == pytest.approx( + [100.0, 150.0, 133.33333333333334, 150.0, 140.0] + ) + assert traces[1]["y"] == pytest.approx([100.0, 75.0, 83.33333333333334, 75.0, 80.0]) + assert traces[2]["x"] == [1, 5] + assert traces[2]["y"] == pytest.approx([100.0, 100.0]) + + cards = summary_card_values(body) + assert set(cards) == { + "Recent Hits Index", + "Recent K Index", + "Trend Gap", + "Games Played", + } + assert displayed_number(cards["Recent Hits Index"]) == pytest.approx(140.0) + assert displayed_number(cards["Recent K Index"]) == pytest.approx(80.0) + assert displayed_number(cards["Trend Gap"]) == pytest.approx(60.0) + assert displayed_number(cards["Games Played"]) == pytest.approx(5.0) + + +def test_page_explains_both_formulas_the_baseline_and_trend_gap( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_exact_comparison(seed, record_coverage) + body = visible_text(comparison_response(client).text) + + assert "Team Hitting Trends Comparison" in body + assert ( + "Hits Index is rolling team Hits/Game divided by MLB Hits/Game, times 100" + in body + ) + assert ( + "Batting Strikeout Index applies the same calculation to batting K/Game" in body + ) + assert "100, meaning MLB average for that metric" in body + assert "Above 100 means above the MLB average, not automatically better" in body + assert "Trend Gap is simply the recent Hits Index minus the recent K Index" in body + assert "not a validated overall offensive-performance statistic" in body + assert "descriptive only" in body + + +def test_page_uses_local_plotly_and_the_required_axes_and_layout_regions( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_exact_comparison(seed, record_coverage) + body = comparison_response(client).text + + assert f'id="{COMPARISON_CHART_DIV_ID}"' in body + assert '' in body + assert "cdn.plot.ly" not in body + assert "Season Game Number" in body + assert "Normalized Index (MLB Avg = 100)" in body + assert "About this chart" in body + for region in ( + 'class="site-header"', + 'class="shell page"', + 'class="controls card"', + 'class="card chart-card chart-card--comparison"', + 'class="summary summary--comparison"', + 'class="about"', + 'class="site-footer"', + ): + assert region in body + + +def test_comparison_page_does_not_add_dead_mockup_controls( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_exact_comparison(seed, record_coverage) + body = visible_text(comparison_response(client).text) + + for dead_control in ("7D", "30D", "60D", "Export", "Players"): + assert dead_control not in body + + +# --- coverage and data-integrity gates ---------------------------------------- + + +@pytest.mark.parametrize( + ("failed", "finished"), + [ + pytest.param(1, True, id="incomplete"), + pytest.param(0, False, id="running"), + ], +) +def test_incomplete_or_running_coverage_withholds_all_normalized_values( + client: TestClient, + seed: SeedFn, + record_coverage: CoverageFn, + failed: int, + finished: bool, +) -> None: + seed([8] * 5, strikeouts=[10] * 5) + seed( + [8] * 5, + strikeouts=[10] * 5, + team_id=TWINS_ID, + team_name=TWINS_NAME, + ) + record_coverage(teams=2, failed=failed, finished=finished) + + response = comparison_response(client) + assert_comparison_unavailable(response) + assert ( + "latest league-season import must have complete coverage" + in visible_text(response.text).lower() + ) + + +def test_no_coverage_record_withholds_all_normalized_values( + client: TestClient, seed: SeedFn +) -> None: + seed([8] * 5, strikeouts=[10] * 5) + seed( + [8] * 5, + strikeouts=[10] * 5, + team_id=TWINS_ID, + team_name=TWINS_NAME, + ) + + response = comparison_response(client) + assert_comparison_unavailable(response) + assert ( + "latest league-season import must have complete coverage" + in visible_text(response.text).lower() + ) + + +def test_complete_coverage_with_a_null_league_strikeout_asks_for_league_reimport( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed([8] * 5, strikeouts=[10] * 5) + seed( + [8] * 5, + strikeouts=[10, 10, 10, 10, None], + team_id=TWINS_ID, + team_name=TWINS_NAME, + ) + record_coverage(teams=2) + + response = comparison_response(client) + assert_comparison_unavailable(response) + body = visible_text(response.text) + assert "re-import" in body.lower() + assert "import_league_season.py --season 2025" in body + + +def test_selected_team_with_a_null_strikeout_asks_for_team_reimport( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed([8] * 5, strikeouts=[10, 10, None, 10, 10]) + seed( + [8] * 5, + strikeouts=[10] * 5, + team_id=TWINS_ID, + team_name=TWINS_NAME, + ) + record_coverage(teams=2) + + response = comparison_response(client) + assert_comparison_unavailable(response) + body = visible_text(response.text) + assert "re-import" in body.lower() + assert "import_team_season.py --team-id 136 --season 2025" in body + assert "import_league_season.py" not in body + + +@pytest.mark.parametrize( + ("mariners_hits", "twins_hits", "mariners_ks", "twins_ks"), + [ + pytest.param([0] * 5, [0] * 5, [8] * 5, [12] * 5, id="zero-hits"), + pytest.param([6] * 5, [10] * 5, [0] * 5, [0] * 5, id="zero-strikeouts"), + ], +) +def test_zero_mlb_baseline_is_protected_and_renders_unavailable( + client: TestClient, + seed: SeedFn, + record_coverage: CoverageFn, + mariners_hits: list[int], + twins_hits: list[int], + mariners_ks: list[int], + twins_ks: list[int], +) -> None: + seed(mariners_hits, strikeouts=mariners_ks) + seed( + twins_hits, + strikeouts=twins_ks, + team_id=TWINS_ID, + team_name=TWINS_NAME, + ) + record_coverage(teams=2) + + response = comparison_response(client) + assert_comparison_unavailable(response) + assert "baseline" in visible_text(response.text).lower() + + +# --- selectors, shareable URLs, and navigation -------------------------------- + + +@pytest.mark.parametrize("window", [5, 10, 15, 30]) +def test_every_supported_rolling_window_is_selected_and_used( + client: TestClient, + seed: SeedFn, + record_coverage: CoverageFn, + window: int, +) -> None: + seed_exact_comparison(seed, record_coverage) + response = comparison_response(client, window=window) + + assert response.status_code == 200 + assert f'' in response.text + assert f"trailing {window}-game team average" in visible_text(response.text) + + +def test_team_season_window_query_round_trips_through_form_and_navigation( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + cubs_id = 112 + cubs_name = "Chicago Cubs" + seed( + [7] * 40, + strikeouts=[9] * 40, + team_id=cubs_id, + team_name=cubs_name, + season=2024, + ) + # A second Cubs season proves 2024 was selected rather than merely being + # the only season available in the control. + seed( + [8] * 2, + strikeouts=[8] * 2, + team_id=cubs_id, + team_name=cubs_name, + season=2025, + ) + seed([9] * 40, strikeouts=[7] * 40, season=2024) + record_coverage(season=2024, teams=2) + + response = comparison_response(client, team_id=cubs_id, season=2024, window=30) + assert response.status_code == 200 + body = response.text + assert "Chicago Cubs — Hits vs Batting Strikeouts" in body + assert '' in body + assert '' in body + assert '' in body + assert 'action="/comparison"' in body + for href in ( + "/?team_id=112&season=2024&window=30", + "/strikeouts?team_id=112&season=2024&window=30", + "/runs?team_id=112&season=2024&window=30", + "/comparison?team_id=112&season=2024&window=30", + ): + assert f'href="{href}"' in body + assert body.count('aria-current="page"') == 1 + + +# --- DB-only rendering and existing-route regressions ------------------------- + + +def test_comparison_browser_rendering_never_calls_the_mlb_api( + client: TestClient, + seed: SeedFn, + record_coverage: CoverageFn, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail(*args: object, **kwargs: object) -> None: + raise AssertionError("The web layer must not reach the MLB Stats API") + + seed_exact_comparison(seed, record_coverage) + monkeypatch.setattr(requests.Session, "request", fail) + monkeypatch.setattr("mlbstatsapi.Mlb.__init__", fail) + monkeypatch.setattr("app.services.team_game_logs.get_team_game_batting_lines", fail) + monkeypatch.setattr("app.services.league_teams.discover_mlb_teams", fail) + monkeypatch.setattr( + "app.services.league_season_ingestion.ingest_league_season", fail + ) + + response = comparison_response(client, window=15) + assert response.status_code == 200 + assert COMPARISON_CHART_DIV_ID in response.text + + +@pytest.mark.parametrize( + ("path", "heading", "chart_id"), + [ + pytest.param( + "/", "Seattle Mariners — Hits per Game", "team-hits-chart", id="hits" + ), + pytest.param( + "/strikeouts", + "Seattle Mariners — Batting Strikeouts per Game", + "team-strikeouts-chart", + id="strikeouts", + ), + pytest.param( + "/runs", + "Seattle Mariners — Runs Scored per Game", + "team-runs-chart", + id="runs", + ), + ], +) +def test_existing_metric_pages_are_unchanged( + client: TestClient, + seed: SeedFn, + record_coverage: CoverageFn, + path: str, + heading: str, + chart_id: str, +) -> None: + seed_exact_comparison(seed, record_coverage) + response = client.get(f"{path}?team_id=136&season=2025&window=15") + + assert response.status_code == 200 + assert heading in response.text + assert chart_id in response.text + + +def test_health_is_unchanged(client: TestClient) -> None: + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == { + "status": "ok", + "app": "mlb-stats-visualizer", + }