diff --git a/app/analytics/player_hitting.py b/app/analytics/player_hitting.py
new file mode 100644
index 0000000..0ff58d1
--- /dev/null
+++ b/app/analytics/player_hitting.py
@@ -0,0 +1,133 @@
+"""Season-level hitting rates for one player.
+
+Answers one question:
+
+ What did this player's overall offensive season look like?
+
+The input is one stored season aggregate, not a list of games, so this is a
+single calculation rather than a trend: no rolling windows, no chart points,
+and no comparison with MLB. For an in-progress season the aggregate reflects
+the most recent import, and nothing here implies the season is complete. A
+player who played for more than one club is described by the combined season
+aggregate; the individual team stints are not modelled.
+
+Every rate is a ratio of season totals. A rate whose denominator is zero is
+undefined and returned as ``None``, never ``0.0``: a player with no at-bats did
+not bat ``.000``, and presenting it that way would fabricate a statistic.
+
+Like the rest of the package this layer is free of FastAPI, Jinja, SQLAlchemy,
+Plotly, and the MLB API. Values keep full precision; rounding is presentation.
+"""
+
+from app.schemas.analytics import PlayerHittingOverview, PlayerPlateAppearanceRates
+from app.schemas.players import PlayerSeasonHitting
+
+
+class PlayerHittingAnalysisError(ValueError):
+ """A stored hitting line contradicts itself and cannot be described."""
+
+
+def build_player_hitting_overview(
+ hitting: PlayerSeasonHitting,
+) -> PlayerHittingOverview:
+ """Derive AVG, OBP, SLG, OPS, and the plate-appearance rate profile.
+
+ Formulas, all over season totals:
+
+ - ``TB = H + 2B + 2 * 3B + 3 * HR``. ``H`` already counts the first base
+ of every hit, so each extra-base hit adds only its extra bases.
+ - ``AVG = H / AB``
+ - ``OBP = (H + BB + HBP) / (AB + BB + HBP + SF)``. Sacrifice bunts are not
+ in the denominator.
+ - ``SLG = TB / AB``
+ - ``OPS = OBP + SLG``, undefined when either component is.
+ - ``K% = SO / PA``, ``BB% = BB / PA``, ``HR% = HR / PA``.
+
+ Raises
+ ------
+ PlayerHittingAnalysisError
+ The line records more of an outcome than the plate appearances or
+ at-bats that contain it, which no real season can.
+ """
+ _require_subset_counts(hitting)
+
+ total_bases = (
+ hitting.hits + hitting.doubles + 2 * hitting.triples + 3 * hitting.home_runs
+ )
+ batting_average = _ratio(hitting.hits, hitting.at_bats)
+ on_base_percentage = _ratio(
+ hitting.hits + hitting.base_on_balls + hitting.hit_by_pitch,
+ hitting.at_bats
+ + hitting.base_on_balls
+ + hitting.hit_by_pitch
+ + hitting.sac_flies,
+ )
+ slugging_percentage = _ratio(total_bases, hitting.at_bats)
+ on_base_plus_slugging = (
+ None
+ if on_base_percentage is None or slugging_percentage is None
+ else on_base_percentage + slugging_percentage
+ )
+
+ return PlayerHittingOverview(
+ hitting=hitting,
+ total_bases=total_bases,
+ batting_average=batting_average,
+ on_base_percentage=on_base_percentage,
+ slugging_percentage=slugging_percentage,
+ on_base_plus_slugging=on_base_plus_slugging,
+ plate_appearance_rates=_plate_appearance_rates(hitting),
+ )
+
+
+def _require_subset_counts(hitting: PlayerSeasonHitting) -> None:
+ """Reject a line whose numerators exceed the totals that contain them.
+
+ ``PlayerSeasonHitting`` does not prove these relationships, and each one
+ bounds a rate derived here. Checking them explicitly turns a corrupted
+ stored row into a named data-integrity error rather than a schema failure
+ while constructing the analysis. A batting strikeout always ends an
+ at-bat, so it is bounded by at-bats as well as plate appearances.
+
+ Plate-appearance bounds are checked first so the error names the rate
+ denominator that was actually exceeded.
+ """
+ plate_appearances = hitting.plate_appearances
+ for count, count_name, total, total_name in (
+ (hitting.strikeouts, "strikeouts", plate_appearances, "plate appearances"),
+ (hitting.base_on_balls, "walks", plate_appearances, "plate appearances"),
+ (hitting.home_runs, "home runs", plate_appearances, "plate appearances"),
+ (hitting.hits, "hits", hitting.at_bats, "at-bats"),
+ (hitting.strikeouts, "strikeouts", hitting.at_bats, "at-bats"),
+ ):
+ if count > total:
+ raise PlayerHittingAnalysisError(
+ f"Player {hitting.player_id}'s stored {hitting.season} hitting "
+ f"line records {count} {count_name} in {total} {total_name}; "
+ f"{count_name} cannot exceed {total_name}"
+ )
+
+
+def _plate_appearance_rates(
+ hitting: PlayerSeasonHitting,
+) -> PlayerPlateAppearanceRates | None:
+ """Return K%, BB%, and HR% together, or None with no plate appearances."""
+ plate_appearances = hitting.plate_appearances
+ if plate_appearances == 0:
+ return None
+ return PlayerPlateAppearanceRates(
+ plate_appearances=plate_appearances,
+ strikeouts=hitting.strikeouts,
+ base_on_balls=hitting.base_on_balls,
+ home_runs=hitting.home_runs,
+ strikeout_rate=hitting.strikeouts / plate_appearances,
+ walk_rate=hitting.base_on_balls / plate_appearances,
+ home_run_rate=hitting.home_runs / plate_appearances,
+ )
+
+
+def _ratio(numerator: int, denominator: int) -> float | None:
+ """Divide, or return None when the rate is undefined."""
+ if denominator == 0:
+ return None
+ return numerator / denominator
diff --git a/app/database/repositories.py b/app/database/repositories.py
index 27cfcc3..50546ee 100644
--- a/app/database/repositories.py
+++ b/app/database/repositories.py
@@ -528,6 +528,46 @@ def list_player_catalog_seasons(session: Session) -> list[int]:
) from exc
+def get_player_catalog_entry(
+ session: Session,
+ *,
+ player_id: int,
+ season: int,
+) -> PlayerSeasonCatalogEntry | None:
+ """Return one player's catalog membership for one season, or None.
+
+ Membership comes from ``player_seasons`` alone. A stored identity or a
+ stored hitting row does not by itself place a player in a season.
+ """
+ stmt = (
+ select(PlayerSeasonCatalogRecord, PlayerRecord)
+ .join(
+ PlayerRecord,
+ PlayerRecord.player_id == PlayerSeasonCatalogRecord.player_id,
+ )
+ .where(
+ PlayerSeasonCatalogRecord.player_id == player_id,
+ PlayerSeasonCatalogRecord.season == season,
+ )
+ )
+ try:
+ row = session.execute(stmt).one_or_none()
+ except OperationalError as exc:
+ message = str(exc.orig if exc.orig is not None else exc).lower()
+ if "no such table" not in message or not (
+ "player_seasons" in message or "players" in message
+ ):
+ raise
+ raise DatabaseSchemaMissingError(
+ "Player catalog tables are missing. "
+ f"Apply migrations with: {MIGRATION_HINT}"
+ ) from exc
+ if row is None:
+ return None
+ membership, player = row
+ return membership.to_domain(player.to_domain())
+
+
def list_player_catalog(
session: Session, *, season: int
) -> list[PlayerSeasonCatalogEntry]:
diff --git a/app/schemas/analytics.py b/app/schemas/analytics.py
index c04c21f..d78c902 100644
--- a/app/schemas/analytics.py
+++ b/app/schemas/analytics.py
@@ -1,4 +1,4 @@
-"""Schemas for calculated team hitting analytics.
+"""Schemas for calculated team and player analytics.
These models are the contract between the analytics layer and everything that
presents it. They carry finished numbers, not raw MLB payloads, and they keep
@@ -8,6 +8,9 @@
than through a shared metric type. They are read the same way but mean
different things, and honest duplication is cheaper to follow than an
abstraction covering four cases.
+
+Player models describe one stored season aggregate rather than a game-by-game
+trend, so they carry no chart points, rolling windows, or league context.
"""
from __future__ import annotations
@@ -18,6 +21,7 @@
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.schemas.games import HomeAway
+from app.schemas.players import PlayerSeasonHitting
def _validate_prior_window_pair(
@@ -1676,3 +1680,172 @@ def _comparison_is_internally_consistent(self) -> TeamHitsAllowedLeagueCompariso
f"team_hits_allowed_per_game - league.hits_per_game ({expected})"
)
return self
+
+
+def _validate_optional_ratio(
+ name: str,
+ value: float | None,
+ *,
+ numerator: int,
+ denominator: int,
+) -> None:
+ """Require a rate to equal its components, and be None only at zero.
+
+ An undefined rate and a real ``.000`` rate mean different things, so a zero
+ denominator must produce None and a non-zero one must produce the ratio.
+ """
+ if denominator == 0:
+ if value is not None:
+ raise ValueError(f"{name} must be None when its denominator is zero")
+ return
+ expected = numerator / denominator
+ if value is None or not isclose(value, expected, rel_tol=1e-9, abs_tol=1e-9):
+ raise ValueError(
+ f"{name} ({value}) must equal {numerator} / {denominator} ({expected})"
+ )
+
+
+class PlayerPlateAppearanceRates(BaseModel):
+ """Share of a player's plate appearances ending in three outcomes.
+
+ All three rates share ``plate_appearances`` as their denominator, which is
+ why they are grouped: they describe how the player's stored season
+ outcomes were composed, not how good those outcomes were. The model only
+ exists when the player had at least one plate appearance.
+ """
+
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ plate_appearances: int = Field(gt=0, description="The shared denominator.")
+ strikeouts: int = Field(ge=0, description="Batting strikeouts.")
+ base_on_balls: int = Field(ge=0, description="Walks, including intentional.")
+ home_runs: int = Field(ge=0, description="Home runs.")
+ strikeout_rate: float = Field(
+ ge=0, le=1, description="K%: strikeouts / plate_appearances."
+ )
+ walk_rate: float = Field(
+ ge=0, le=1, description="BB%: base_on_balls / plate_appearances."
+ )
+ home_run_rate: float = Field(
+ ge=0, le=1, description="HR%: home_runs / plate_appearances."
+ )
+
+ @model_validator(mode="after")
+ def _rates_match_their_components(self) -> PlayerPlateAppearanceRates:
+ for name, numerator in (
+ ("strikeout_rate", self.strikeouts),
+ ("walk_rate", self.base_on_balls),
+ ("home_run_rate", self.home_runs),
+ ):
+ _validate_optional_ratio(
+ name,
+ getattr(self, name),
+ numerator=numerator,
+ denominator=self.plate_appearances,
+ )
+ return self
+
+
+class PlayerHittingOverview(BaseModel):
+ """One player's stored season hitting aggregate with its derived rates.
+
+ The stored season aggregate is carried unchanged beside the rates derived
+ from it, and the validators below prove the two agree. Each rate is None
+ exactly when its denominator is zero, never ``0.0``. OPS is None whenever
+ either OBP or SLG is.
+
+ This is the season aggregate as currently stored, which for an in-progress
+ season is the total at the most recent import rather than a completed
+ season. It is not a trend, does not compare the player with MLB, and says
+ nothing about which club or clubs the player played for.
+ """
+
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ hitting: PlayerSeasonHitting
+ total_bases: int = Field(ge=0, description="H + 2B + 2 * 3B + 3 * HR.")
+ batting_average: float | None = Field(
+ ge=0, le=1, description="AVG: hits / at_bats, or None with no at-bats."
+ )
+ on_base_percentage: float | None = Field(
+ ge=0,
+ le=1,
+ description="OBP: (H + BB + HBP) / (AB + BB + HBP + SF), or None when "
+ "that denominator is zero.",
+ )
+ slugging_percentage: float | None = Field(
+ ge=0, le=4, description="SLG: total_bases / at_bats, or None with no at-bats."
+ )
+ on_base_plus_slugging: float | None = Field(
+ ge=0, le=5, description="OPS: OBP + SLG, or None when either is None."
+ )
+ plate_appearance_rates: PlayerPlateAppearanceRates | None = Field(
+ description="K%, BB%, and HR%, or None with no plate appearances."
+ )
+
+ @model_validator(mode="after")
+ def _rates_match_the_stored_line(self) -> PlayerHittingOverview:
+ line = self.hitting
+ expected_total_bases = (
+ line.hits + line.doubles + 2 * line.triples + 3 * line.home_runs
+ )
+ if self.total_bases != expected_total_bases:
+ raise ValueError(
+ f"total_bases ({self.total_bases}) must equal H + 2B + 2 * 3B + "
+ f"3 * HR ({expected_total_bases})"
+ )
+ _validate_optional_ratio(
+ "batting_average",
+ self.batting_average,
+ numerator=line.hits,
+ denominator=line.at_bats,
+ )
+ _validate_optional_ratio(
+ "on_base_percentage",
+ self.on_base_percentage,
+ numerator=line.hits + line.base_on_balls + line.hit_by_pitch,
+ denominator=(
+ line.at_bats + line.base_on_balls + line.hit_by_pitch + line.sac_flies
+ ),
+ )
+ _validate_optional_ratio(
+ "slugging_percentage",
+ self.slugging_percentage,
+ numerator=self.total_bases,
+ denominator=line.at_bats,
+ )
+ if self.on_base_percentage is None or self.slugging_percentage is None:
+ if self.on_base_plus_slugging is not None:
+ raise ValueError(
+ "on_base_plus_slugging must be None when OBP or SLG is None"
+ )
+ else:
+ expected_ops = self.on_base_percentage + self.slugging_percentage
+ if self.on_base_plus_slugging is None or not isclose(
+ self.on_base_plus_slugging, expected_ops, rel_tol=1e-9, abs_tol=1e-9
+ ):
+ raise ValueError(
+ f"on_base_plus_slugging ({self.on_base_plus_slugging}) must "
+ f"equal OBP + SLG ({expected_ops})"
+ )
+ rates = self.plate_appearance_rates
+ if (rates is None) != (line.plate_appearances == 0):
+ raise ValueError(
+ "plate_appearance_rates must be present exactly when "
+ "plate_appearances is non-zero"
+ )
+ if rates is not None and (
+ rates.plate_appearances,
+ rates.strikeouts,
+ rates.base_on_balls,
+ rates.home_runs,
+ ) != (
+ line.plate_appearances,
+ line.strikeouts,
+ line.base_on_balls,
+ line.home_runs,
+ ):
+ raise ValueError(
+ "plate_appearance_rates components must match the stored line"
+ )
+ return self
diff --git a/app/web/charts.py b/app/web/charts.py
index 98bbb9c..ed6ef76 100644
--- a/app/web/charts.py
+++ b/app/web/charts.py
@@ -1,4 +1,4 @@
-"""Plotly figure construction for team analytics visualizations.
+"""Plotly figure construction for team and player analytics visualizations.
Kept out of the route so the figure contract can be tested without HTTP and so
the route stays about request handling.
@@ -18,6 +18,7 @@
from app.analytics.team_pitching import build_pitch_count_points
from app.schemas.analytics import (
+ PlayerPlateAppearanceRates,
TeamBaserunnersAnalysis,
TeamBaserunnersLeagueComparison,
TeamHitsAllowedAnalysis,
@@ -33,7 +34,12 @@
TeamStrikeoutsAnalysis,
TeamStrikeoutsLeagueComparison,
)
-from app.web.formatting import format_long_date, format_matchup, format_short_date
+from app.web.formatting import (
+ format_long_date,
+ format_matchup,
+ format_plate_appearance_rate,
+ format_short_date,
+)
CHART_DIV_ID = "team-hits-chart"
RAW_HITS_TRACE_NAME = "Game Hits"
@@ -80,6 +86,9 @@
NORMALIZED_BASELINE_TRACE_NAME = "Baseline (100)"
COMPARISON_Y_AXIS_TITLE = "Normalized Index (MLB Avg = 100)"
+PLAYER_PA_RATES_CHART_DIV_ID = "player-plate-appearance-rates-chart"
+PLAYER_PA_RATES_X_AXIS_TITLE = "Percentage of Plate Appearances"
+
_NAVY = "#12263f"
_TEAL = "#0f8b8d"
# Distinct hue *and* distinct dash from the navy team line, so the two
@@ -1117,6 +1126,86 @@ def build_team_hitting_comparison_figure(
return figure
+def build_player_plate_appearance_rates_figure(
+ rates: PlayerPlateAppearanceRates,
+) -> go.Figure:
+ """Build the K%, BB%, and HR% bars for one player-season.
+
+ The three bars share one denominator, plate appearances, so their lengths
+ are directly comparable. One colour is used for all three: a strikeout is
+ not styled as bad or a walk as good, and there is no MLB reference line.
+ The chart describes how the stored season's outcomes were composed.
+ """
+ bars = (
+ ("K%", "Strikeouts", rates.strikeouts, rates.strikeout_rate),
+ ("BB%", "Walks", rates.base_on_balls, rates.walk_rate),
+ ("HR%", "Home runs", rates.home_runs, rates.home_run_rate),
+ )
+ labels = [label for label, _, _, _ in bars]
+ values = [value for _, _, _, value in bars]
+ hover_data = [
+ (name, f"{count:,}", f"{rates.plate_appearances:,}")
+ for _, name, count, _ in bars
+ ]
+
+ figure = go.Figure()
+ figure.add_trace(
+ go.Bar(
+ x=values,
+ y=labels,
+ orientation="h",
+ marker={"color": _TEAL},
+ text=[format_plate_appearance_rate(value) for value in values],
+ textposition="outside",
+ cliponaxis=False,
+ textfont={"size": 13, "color": _NAVY},
+ customdata=hover_data,
+ hovertemplate=(
+ "%{y}: %{x:.1%} "
+ "%{customdata[0]}: %{customdata[1]} of "
+ "%{customdata[2]} PA"
+ ),
+ showlegend=False,
+ )
+ )
+ # Headroom for the outside value labels without implying a scale maximum.
+ upper = max(max(values) * 1.2, 0.05)
+ figure.update_layout(
+ template="plotly_white",
+ margin={"l": 8, "r": 16, "t": 8, "b": 8},
+ height=240,
+ 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},
+ bargap=0.35,
+ xaxis={
+ "title": {
+ "text": PLAYER_PA_RATES_X_AXIS_TITLE,
+ "standoff": 10,
+ "font": _AXIS_TITLE_FONT,
+ },
+ "tickfont": _TICK_FONT,
+ "tickformat": ".0%",
+ "range": [0, upper],
+ "gridcolor": _GRID,
+ "griddash": "dot",
+ "zeroline": False,
+ "showline": True,
+ "linecolor": _AXIS_LINE,
+ "automargin": True,
+ },
+ yaxis={
+ # K% first, reading top to bottom in the order the page lists them.
+ "autorange": "reversed",
+ "tickfont": {"size": 13, "color": _NAVY},
+ "showgrid": False,
+ "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 b1f065d..c80e2c1 100644
--- a/app/web/formatting.py
+++ b/app/web/formatting.py
@@ -4,6 +4,7 @@
from datetime import date
from app.schemas.analytics import (
+ PlayerHittingOverview,
TeamBaserunnersAnalysis,
TeamBaserunnersLeagueComparison,
TeamHitsAllowedAnalysis,
@@ -39,6 +40,9 @@
)
NORMALIZED_INDEX_CAPTION = "MLB Avg = 100"
NO_LEAGUE_COMPARISON_VALUE = "—"
+# A rate with a zero denominator. Its card caption always says why, so the dash
+# cannot be read as a real ``.000``.
+UNDEFINED_RATE_VALUE = "—"
NO_LEAGUE_COMPARISON_CAPTION = "Comparison unavailable"
LEAGUE_COMPARISON_UNAVAILABLE_NOTE = (
"MLB comparison unavailable. A complete league-season import is "
@@ -576,6 +580,18 @@ def format_win_pct(value: float) -> str:
or winless season is written ``1.000`` and ``.000``, so the leading digit
is kept only when it is not a zero.
"""
+ return _format_three_decimal_rate(value)
+
+
+def format_batting_rate(value: float) -> str:
+ """Render AVG, OBP, SLG, or OPS the way a box score does: ``.300``.
+
+ OPS routinely passes one and keeps its leading digit, as in ``1.024``.
+ """
+ return _format_three_decimal_rate(value)
+
+
+def _format_three_decimal_rate(value: float) -> str:
rendered = f"{value:.3f}"
return rendered[1:] if rendered.startswith("0.") else rendered
@@ -855,3 +871,101 @@ def format_hits_allowed_direction_sentence(
f"{team_name}'s pitchers allowed {abs(difference):.2f} {direction} hits "
f"per game than MLB overall across the stored season."
)
+
+
+def format_plate_appearance_rate(value: float) -> str:
+ """Render a share of plate appearances, such as K%, to one decimal."""
+ return f"{value:.1%}"
+
+
+def build_player_hitting_rate_cards(
+ overview: PlayerHittingOverview,
+) -> list[SummaryCard]:
+ """Build the AVG, OBP, SLG, and OPS cards for one player-season.
+
+ An undefined rate is shown as ``—`` with a caption naming the missing
+ denominator, never as ``.000``.
+ """
+ line = overview.hitting
+ at_bats_caption = (
+ f"{line.hits:,} H in {line.at_bats:,} AB"
+ if overview.batting_average is not None
+ else "Undefined: no at-bats"
+ )
+ return [
+ SummaryCard(
+ label="AVG",
+ value=_format_optional_batting_rate(overview.batting_average),
+ caption=at_bats_caption,
+ ),
+ SummaryCard(
+ label="OBP",
+ value=_format_optional_batting_rate(overview.on_base_percentage),
+ caption=(
+ "On-base percentage"
+ if overview.on_base_percentage is not None
+ else "Undefined: no AB, BB, HBP, or SF"
+ ),
+ ),
+ SummaryCard(
+ label="SLG",
+ value=_format_optional_batting_rate(overview.slugging_percentage),
+ caption=(
+ f"{overview.total_bases:,} TB in {line.at_bats:,} AB"
+ if overview.slugging_percentage is not None
+ else "Undefined: no at-bats"
+ ),
+ ),
+ SummaryCard(
+ label="OPS",
+ value=_format_optional_batting_rate(overview.on_base_plus_slugging),
+ caption=(
+ "OBP + SLG"
+ if overview.on_base_plus_slugging is not None
+ else "Undefined: needs OBP and SLG"
+ ),
+ ),
+ ]
+
+
+def build_player_hitting_total_cards(
+ overview: PlayerHittingOverview,
+) -> list[SummaryCard]:
+ """Build the season counting-stat cards, exactly as stored."""
+ line = overview.hitting
+ return [
+ SummaryCard(
+ label="Games",
+ value=f"{line.games_played:,}",
+ caption="Games played",
+ ),
+ SummaryCard(
+ label="Plate Appearances",
+ value=f"{line.plate_appearances:,}",
+ caption=f"{line.at_bats:,} at-bats",
+ ),
+ SummaryCard(
+ label="Home Runs",
+ value=f"{line.home_runs:,}",
+ caption="Season total",
+ ),
+ SummaryCard(
+ label="Walks",
+ value=f"{line.base_on_balls:,}",
+ caption=f"Includes {line.intentional_walks:,} intentional",
+ ),
+ SummaryCard(
+ label="Strikeouts",
+ value=f"{line.strikeouts:,}",
+ caption="Batting strikeouts",
+ ),
+ SummaryCard(
+ label="Stolen Bases",
+ value=f"{line.stolen_bases:,}",
+ caption=f"{line.caught_stealing:,} caught stealing",
+ ),
+ ]
+
+
+def _format_optional_batting_rate(value: float | None) -> str:
+ return UNDEFINED_RATE_VALUE if value is None else format_batting_rate(value)
diff --git a/app/web/player_routes.py b/app/web/player_routes.py
index 1f2aeeb..8fb6978 100644
--- a/app/web/player_routes.py
+++ b/app/web/player_routes.py
@@ -1,29 +1,64 @@
-"""DB-only Player directory, search, and season-scoped selection."""
+"""DB-only Player directory, selection, and season hitting overview."""
from typing import Annotated
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Query, Request
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
+from app.analytics.player_hitting import (
+ PlayerHittingAnalysisError,
+ build_player_hitting_overview,
+)
from app.config import Settings
from app.database.repositories import (
MIGRATION_HINT,
DatabaseSchemaMissingError,
+ get_player_catalog_entry,
+ get_player_season_hitting,
list_player_catalog,
list_player_catalog_seasons,
)
from app.schemas.players import PlayerSeasonCatalogEntry, normalize_player_name
+from app.web.charts import (
+ PLAYER_PA_RATES_CHART_DIV_ID,
+ build_player_plate_appearance_rates_figure,
+ render_figure_html,
+)
from app.web.dependencies import get_db_session
-from app.web.routes import MLB_LOGO_URL
+from app.web.formatting import (
+ build_player_hitting_rate_cards,
+ build_player_hitting_total_cards,
+)
+from app.web.routes import MLB_LOGO_URL, PLOTLY_BUNDLE_PATH
# A fixed cap keeps broad searches readable without a pagination framework.
PLAYER_SEARCH_LIMIT = 50
PLAYER_CATALOG_IMPORT_COMMAND = (
"poetry run python scripts/import_player_catalog.py --season {season}"
)
+PLAYER_SEASON_IMPORT_COMMAND = (
+ "poetry run python scripts/import_player_season.py "
+ "--player-id {player_id} --season {season}"
+)
+PLAYER_DIRECTORY_PATH = "/players"
+PLAYER_HITTING_PATH = "/players/hitting"
+
+
+def player_hitting_url(*, season: int, player_id: int) -> str:
+ """Return the shareable hitting overview URL for one Player-season."""
+ return f"{PLAYER_HITTING_PATH}?" + urlencode(
+ {"season": season, "player_id": player_id}
+ )
+
+
+def player_selection_url(*, season: int, player_id: int) -> str:
+ """Return the directory URL with this Player-season already selected."""
+ return f"{PLAYER_DIRECTORY_PATH}?" + urlencode(
+ {"season": season, "player_id": player_id}
+ )
def search_player_catalog(
@@ -45,10 +80,10 @@ def search_player_catalog(
def create_player_router(templates: Jinja2Templates, settings: Settings) -> APIRouter:
- """Build the one real Player-domain destination."""
+ """Build the Player directory and the Player season hitting overview."""
router = APIRouter()
- @router.get("/players", response_class=HTMLResponse)
+ @router.get(PLAYER_DIRECTORY_PATH, response_class=HTMLResponse)
def player_directory(
request: Request,
session: Annotated[Session, Depends(get_db_session)],
@@ -86,6 +121,18 @@ def player_directory(
)
status = 404
+ # A local read only, to decide whether a real overview link exists.
+ # The directory itself never renders the stats.
+ hitting_overview_url = None
+ if selected_player is not None and season is not None:
+ hitting = get_player_season_hitting(
+ session, player_id=selected_player.player_id, season=season
+ )
+ if hitting is not None:
+ hitting_overview_url = player_hitting_url(
+ season=season, player_id=selected_player.player_id
+ )
+
query = q.strip()
matches = search_player_catalog(catalog, query)
results = matches[:PLAYER_SEARCH_LIMIT]
@@ -109,6 +156,14 @@ def player_directory(
"result_count": len(matches),
"result_limit": PLAYER_SEARCH_LIMIT,
"selected_player": selected_player,
+ "hitting_overview_url": hitting_overview_url,
+ "player_season_import_command": (
+ PLAYER_SEASON_IMPORT_COMMAND.format(
+ player_id=selected_player.player_id, season=season
+ )
+ if selected_player is not None
+ else None
+ ),
"message": message,
"schema_missing": schema_missing,
"migration_command": MIGRATION_HINT,
@@ -119,4 +174,80 @@ def player_directory(
status_code=status,
)
+ @router.get(PLAYER_HITTING_PATH, response_class=HTMLResponse)
+ def player_hitting(
+ request: Request,
+ session: Annotated[Session, Depends(get_db_session)],
+ season: Annotated[int, Query(gt=0)],
+ player_id: Annotated[int, Query(gt=0)],
+ ) -> Response:
+ """Render one Player's stored season hitting line from the database only."""
+ context: dict[str, object] = {
+ "app_name": settings.app_name,
+ "mlb_logo_url": MLB_LOGO_URL,
+ "season": season,
+ "player": None,
+ "directory_url": PLAYER_DIRECTORY_PATH,
+ }
+
+ def render(state: str, status_code: int = 200) -> Response:
+ context["state"] = state
+ return templates.TemplateResponse(
+ request=request,
+ name="player_hitting.html",
+ context=context,
+ status_code=status_code,
+ )
+
+ try:
+ player = get_player_catalog_entry(
+ session, player_id=player_id, season=season
+ )
+ except DatabaseSchemaMissingError as exc:
+ context["message"] = str(exc)
+ context["migration_command"] = MIGRATION_HINT
+ return render("schema_missing", 503)
+
+ # Membership comes from the season's catalog, never from a stored
+ # identity or a stored hitting row alone.
+ if player is None:
+ context["message"] = (
+ f"Player {player_id} is not in the locally stored {season} "
+ "Player catalog."
+ )
+ return render("not_found", 404)
+
+ context["player"] = player
+ context["directory_url"] = player_selection_url(
+ season=season, player_id=player_id
+ )
+ context["import_command"] = PLAYER_SEASON_IMPORT_COMMAND.format(
+ player_id=player_id, season=season
+ )
+
+ hitting = get_player_season_hitting(session, player_id=player_id, season=season)
+ if hitting is None:
+ # The selection is valid; only the analytics are unavailable, like
+ # Team comparison without league data. Not zeroes, not an MLB call.
+ return render("missing_hitting")
+
+ try:
+ overview = build_player_hitting_overview(hitting)
+ except PlayerHittingAnalysisError as exc:
+ context["message"] = str(exc)
+ return render("inconsistent_hitting", 409)
+
+ context["overview"] = overview
+ context["rate_cards"] = build_player_hitting_rate_cards(overview)
+ context["total_cards"] = build_player_hitting_total_cards(overview)
+ if overview.plate_appearance_rates is not None:
+ context["plotly_bundle_path"] = PLOTLY_BUNDLE_PATH
+ context["chart_html"] = render_figure_html(
+ build_player_plate_appearance_rates_figure(
+ overview.plate_appearance_rates
+ ),
+ div_id=PLAYER_PA_RATES_CHART_DIV_ID,
+ )
+ return render("ok")
+
return router
diff --git a/app/web/static/css/app.css b/app/web/static/css/app.css
index 4b62f62..50d3d91 100644
--- a/app/web/static/css/app.css
+++ b/app/web/static/css/app.css
@@ -415,6 +415,33 @@ body {
font-size: 0.85rem;
}
+/* Player hitting overview */
+
+.player-back {
+ margin: 0;
+}
+
+.player-link {
+ color: var(--teal-dark);
+ font-weight: 600;
+}
+
+.player-link:hover {
+ color: var(--teal);
+}
+
+.player-link:focus-visible {
+ outline: 2px solid var(--teal);
+ outline-offset: 2px;
+ border-radius: 2px;
+}
+
+.player-section-heading {
+ margin: 0.25rem 0 -0.5rem;
+ font-size: 1.1rem;
+ color: var(--navy);
+}
+
/* Chart */
.chart-card {
@@ -457,7 +484,8 @@ body {
/* 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 {
+.chart-card__figure--comparison .plotly-graph-div,
+.chart-card__figure--player-rates .plotly-graph-div {
min-width: 0;
}
@@ -469,6 +497,10 @@ body {
gap: 1rem;
}
+.summary--player-totals {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
.summary-card {
padding: 1.1rem 1.25rem 1.15rem;
}
@@ -687,6 +719,11 @@ body {
grid-template-columns: minmax(0, 1fr);
}
+ /* Short counts and three-digit rates fit two to a row on a phone. */
+ .summary--player-totals {
+ grid-template-columns: repeat(2, 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. */
diff --git a/app/web/templates/player_base.html b/app/web/templates/player_base.html
new file mode 100644
index 0000000..4cd2dae
--- /dev/null
+++ b/app/web/templates/player_base.html
@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+
+{# Players is the current domain on every Player page. There is no secondary
+ Player navigation: each Player page needs a selected Player-season, so a
+ global link to it would have nowhere real to go. #}
+{% block primary_navigation %}
+ Teams
+ Players
+{% endblock %}
diff --git a/app/web/templates/player_hitting.html b/app/web/templates/player_hitting.html
new file mode 100644
index 0000000..2088099
--- /dev/null
+++ b/app/web/templates/player_hitting.html
@@ -0,0 +1,134 @@
+{% extends "player_base.html" %}
+
+{% block title %}
+ {%- if player -%}
+ {{ player.full_name }} — {{ season }} Hitting
+ {%- else -%}
+ Player Hitting Overview
+ {%- endif %} — {{ app_name }}
+{% endblock %}
+
+{% block head %}
+ {# plotly.js must be parsed before the figure div's inline bootstrap script. #}
+ {% if chart_html %}
+
+ {% endif %}
+{% endblock %}
+
+{% block content %}
+
No {{ season }} hitting line is stored for {{ player.full_name }}
+
+ {{ player.full_name }} is in the stored {{ season }} Player catalog, but
+ their season hitting statistics have not been imported. Nothing is
+ shown rather than treating the missing line as zero production.
+
+
Import it from the command line, then reload this page:
+
{{ import_command }}
+
+ {% elif state == "inconsistent_hitting" %}
+
+
The stored {{ season }} hitting line cannot be summarized
+
{{ message }}
+
Re-import the season line to replace it:
+
{{ import_command }}
+
+ {% else %}
+
Rate Stats
+ {% with summary_cards=rate_cards, summary_label="Season rate statistics" %}
+ {% include "_summary_cards.html" %}
+ {% endwith %}
+
+
Season Totals
+ {% with summary_cards=total_cards, summary_class="summary summary--player-totals", summary_label="Season counting statistics" %}
+ {% include "_summary_cards.html" %}
+ {% endwith %}
+
+ {% if chart_html %}
+
+
+
Plate Appearance Rate Profile
+
+ K%, BB%, and HR% as percentages of {{ "{:,}".format(overview.hitting.plate_appearances) }} plate appearances
+
+
+
{{ chart_html | safe }}
+
+ {% else %}
+
+
Plate Appearance Rate Profile
+
K%, BB%, and HR% are undefined: no plate appearances are stored for this season.
+
+ {% endif %}
+
+
+
+
+
+
+
About this overview
+
+ These figures describe {{ player.full_name }}'s {{ season }} season
+ aggregate currently stored locally. For an in-progress season, they
+ reflect the data available at the most recent import and do not imply
+ the season is complete.
+
+
+ If the player appeared for more than one club, the stored line is the
+ combined season aggregate; this page does not model the individual
+ team stints.
+
+
+ AVG is hits per at-bat. OBP is (H + BB + HBP) / (AB + BB + HBP + SF).
+ SLG is total bases per at-bat, and OPS is OBP + SLG. A rate shown as
+ — is undefined because its denominator is zero, which is not the
+ same as .000.
+
+
+ K%, BB%, and HR% each divide by plate appearances, so the bars
+ describe how the player's plate appearances ended. They are not a
+ ranking or a quality score, and nothing here is compared with MLB.
+
+
Primary position
+
+ {{ player.primary_position }}. This is the stored identity value, not
+ a historical position for {{ season }}.
+