Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/database/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,25 @@ def get_player(session: Session, *, player_id: int) -> PlayerIdentity | None:
return None if record is None else record.to_domain()


def list_player_catalog_seasons(session: Session) -> list[int]:
"""Return catalog membership seasons, newest first, independently of stats."""
stmt = (
select(PlayerSeasonCatalogRecord.season)
.distinct()
.order_by(PlayerSeasonCatalogRecord.season.desc())
)
try:
return list(session.scalars(stmt))
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 "player_seasons" not in message:
raise
raise DatabaseSchemaMissingError(
"Table 'player_seasons' is missing. "
f"Apply migrations with: {MIGRATION_HINT}"
) from exc


def list_player_catalog(
session: Session, *, season: int
) -> list[PlayerSeasonCatalogEntry]:
Expand Down
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from app.config import get_settings
from app.database.engine import build_engine, build_session_factory
from app.web.errors import create_validation_error_handler
from app.web.player_routes import create_player_router
from app.web.routes import create_router

WEB_DIR = Path(__file__).resolve().parent / "web"
Expand Down Expand Up @@ -52,6 +53,7 @@ def create_app() -> FastAPI:
create_validation_error_handler(templates, settings),
)
application.include_router(create_router(templates, settings))
application.include_router(create_player_router(templates, settings))
return application


Expand Down
16 changes: 9 additions & 7 deletions app/schemas/players.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
from pydantic import BaseModel, ConfigDict, Field, model_validator


def normalize_player_name(name: str) -> str:
"""Fold case and accents consistently for catalog ordering and search."""
decomposed = unicodedata.normalize("NFKD", name)
return "".join(
character for character in decomposed if not unicodedata.combining(character)
).casefold()


class PlayerIdentity(BaseModel):
"""A player's persisted identity fields.

Expand Down Expand Up @@ -36,13 +44,7 @@ def name_sort_key(self) -> tuple[str, str, int]:
the same order. ``full_name`` breaks ties between names that fold
together (``Zoë`` and ``zoë``) and ``player_id`` makes the result total.
"""
decomposed = unicodedata.normalize("NFKD", self.full_name)
folded = "".join(
character
for character in decomposed
if not unicodedata.combining(character)
).casefold()
return (folded, self.full_name, self.player_id)
return (normalize_player_name(self.full_name), self.full_name, self.player_id)


class PlayerSeasonCatalogEntry(PlayerIdentity):
Expand Down
122 changes: 122 additions & 0 deletions app/web/player_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""DB-only Player directory, search, and season-scoped selection."""

from typing import Annotated
from urllib.parse import urlencode

from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session

from app.config import Settings
from app.database.repositories import (
MIGRATION_HINT,
DatabaseSchemaMissingError,
list_player_catalog,
list_player_catalog_seasons,
)
from app.schemas.players import PlayerSeasonCatalogEntry, normalize_player_name
from app.web.dependencies import get_db_session
from app.web.routes import MLB_LOGO_URL

# 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}"
)


def search_player_catalog(
catalog: list[PlayerSeasonCatalogEntry], query: str
) -> list[PlayerSeasonCatalogEntry]:
"""Return catalog members whose name contains the query, keeping catalog order.

Uses the catalog's own case and accent folding, so ``rodriguez`` finds
``Rodríguez``. An empty query matches nobody rather than everybody.
"""
folded_query = normalize_player_name(query)
if not folded_query:
return []
return [
player
for player in catalog
if folded_query in normalize_player_name(player.full_name)
]


def create_player_router(templates: Jinja2Templates, settings: Settings) -> APIRouter:
"""Build the one real Player-domain destination."""
router = APIRouter()

@router.get("/players", response_class=HTMLResponse)
def player_directory(
request: Request,
session: Annotated[Session, Depends(get_db_session)],
season: Annotated[int | None, Query(gt=0)] = None,
q: str = "",
player_id: Annotated[int | None, Query(gt=0)] = None,
) -> HTMLResponse:
seasons: list[int] = []
catalog: list[PlayerSeasonCatalogEntry] = []
message = None
status = 200
schema_missing = False
try:
seasons = list_player_catalog_seasons(session)
if season is None and seasons:
season = seasons[0]
if season is not None and season in seasons:
catalog = list_player_catalog(session, season=season)
elif season is not None:
message = f"No Player catalog is stored locally for {season}."
status = 404
except DatabaseSchemaMissingError as exc:
message = str(exc)
schema_missing = True
status = 503

# Membership is checked against the whole selected-season catalog,
# independently of the search text and the rendered result limit.
selected_player = next(
(player for player in catalog if player.player_id == player_id), None
)
if player_id is not None and selected_player is None and status == 200:
message = f"Player {player_id} is not in the locally stored catalog" + (
f" for {season}." if season is not None else "."
)
status = 404

query = q.strip()
matches = search_player_catalog(catalog, query)
results = matches[:PLAYER_SEARCH_LIMIT]
selection_links = {
player.player_id: "/players?"
+ urlencode({"season": season, "q": query, "player_id": player.player_id})
for player in results
}
return templates.TemplateResponse(
request=request,
name="players.html",
context={
"app_name": settings.app_name,
"mlb_logo_url": MLB_LOGO_URL,
"seasons": seasons,
"season": season,
"q": query,
"has_search": bool(normalize_player_name(query)),
"results": results,
"selection_links": selection_links,
"result_count": len(matches),
"result_limit": PLAYER_SEARCH_LIMIT,
"selected_player": selected_player,
"message": message,
"schema_missing": schema_missing,
"migration_command": MIGRATION_HINT,
"import_command": PLAYER_CATALOG_IMPORT_COMMAND.format(
season=season if season is not None else "<season>"
),
},
status_code=status,
)

return router
56 changes: 56 additions & 0 deletions app/web/static/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,62 @@ body {
background: var(--navy-deep);
}

/* Player directory */

.player-search {
width: 100%;
min-width: 0;
padding: 0.62rem 0.8rem;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
color: var(--text);
font: inherit;
font-size: 0.95rem;
}

.player-search:focus-visible,
.player-results__list a:focus-visible {
outline: 2px solid var(--teal);
outline-offset: 2px;
}

.player-results__list {
list-style: none;
padding: 0;
margin: 0 0 0.8rem;
}

.player-results__list li {
padding: 0.7rem 0;
border-bottom: 1px solid var(--border);
overflow-wrap: anywhere;
}

.player-results__list a {
color: var(--navy);
font-weight: 600;
text-underline-offset: 0.2em;
}

.player-results__list a:hover {
color: var(--teal);
}

.player-selection {
border-left: 4px solid var(--teal);
}

.player-selection p,
.player-results p {
overflow-wrap: anywhere;
}

.player-directory-note {
color: var(--muted);
font-size: 0.85rem;
}

/* Chart */

.chart-card {
Expand Down
1 change: 1 addition & 0 deletions app/web/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<nav class="primary-nav" aria-label="Primary">
{% block primary_navigation %}
<a class="primary-nav__link" href="/">Teams</a>
<a class="primary-nav__link" href="/players">Players</a>
{% endblock %}
</nav>
</div>
Expand Down
105 changes: 105 additions & 0 deletions app/web/templates/players.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
{% extends "base.html" %}

{% block title %}Player Directory — {{ app_name }}{% endblock %}

{% block primary_navigation %}
<a class="primary-nav__link" href="/">Teams</a>
<a class="primary-nav__link" href="/players" aria-current="location">Players</a>
{% endblock %}

{% block content %}
<section class="page-intro">
<h1>Player Directory</h1>
<p class="page-intro__subtitle">Find a player from the locally stored MLB catalog.</p>
</section>

{% if seasons %}
<form class="controls card" method="get" action="/players">
<div class="controls__fields">
<div class="control">
<label for="season">Season</label>
<select id="season" name="season">
{% if season not in seasons %}
<option value="{{ season }}" selected>{{ season }} — not stored</option>
{% endif %}
{% for stored_season in seasons %}
<option value="{{ stored_season }}"{% if stored_season == season %} selected{% endif %}>{{ stored_season }}</option>
{% endfor %}
</select>
</div>
<div class="control">
<label for="q">Search player</label>
<input class="player-search" type="search" id="q" name="q" value="{{ q }}" placeholder="Full name or part of a name" />
</div>
</div>
<div class="controls__action"><button type="submit">Search</button></div>
</form>
{% endif %}

{% if schema_missing %}
<section class="card notice">
<h2>The database schema is not ready</h2>
<p>{{ message }}</p>
<pre class="command"><code>{{ migration_command }}</code></pre>
</section>
{% else %}
{% if not seasons %}
<section class="card notice">
<h2>No Player catalog is stored locally</h2>
{% if message %}<p>{{ message }}</p>{% endif %}
<p>Import a season's catalog from the command line, then reload this page.</p>
{% if season is none %}<p>Replace <code>&lt;season&gt;</code> with the season you want to import.</p>{% endif %}
<pre class="command"><code>{{ import_command }}</code></pre>
</section>
{% endif %}

{% if message and seasons %}
<section class="card notice">
<h2>That Player selection is not stored locally</h2>
<p>{{ message }}</p>
<p>Stored seasons: {{ seasons | join(', ') }}.</p>
</section>
{% endif %}

{% if selected_player %}
<section class="card notice player-selection" aria-labelledby="selected-player-heading">
<h2 id="selected-player-heading">Selected Player</h2>
<p>You selected <strong>{{ selected_player.full_name }}</strong> for the <strong>{{ season }}</strong> catalog.</p>
<p>Primary position: <strong>{{ selected_player.primary_position }}</strong></p>
<p class="player-directory-note">Position is the stored identity value, not a historical position for this season.</p>
</section>
{% endif %}

{% if season in seasons %}
<section class="card notice player-results" aria-labelledby="player-results-heading">
{% if has_search %}
<h2 id="player-results-heading">Search results</h2>
{% if results %}
<p>{{ result_count }} matching player{% if result_count != 1 %}s{% endif %} in the {{ season }} stored catalog.</p>
{% if result_count > result_limit %}
<p>Showing the first {{ result_limit }} results alphabetically. Refine your search to find a player.</p>
{% endif %}
<ul class="player-results__list">
{% for player in results %}
<li>
{% if selected_player and selected_player.player_id == player.player_id %}
<a href="{{ selection_links[player.player_id] }}" aria-current="true">{{ player.full_name }}</a>
<span> — Selected</span>
{% else %}
<a href="{{ selection_links[player.player_id] }}">{{ player.full_name }}</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No players match “{{ q }}” in the {{ season }} stored catalog. Try another name or season.</p>
{% endif %}
{% else %}
<h2 id="player-results-heading">Find a player</h2>
<p>Search by full name or part of a name in the {{ season }} stored catalog. Capitalization and accents do not matter.</p>
{% endif %}
<p class="player-directory-note">Only locally stored catalog members are shown. This directory does not establish complete season coverage.</p>
</section>
{% endif %}
{% endif %}
{% endblock %}
1 change: 1 addition & 0 deletions app/web/templates/team_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
{% block primary_navigation %}
{# Hits is the Team entry point; reuse the scaffold's resolved/requested URL. #}
<a class="primary-nav__link" href="{{ nav_links[0].href }}" aria-current="location">Teams</a>
<a class="primary-nav__link" href="/players">Players</a>
{% endblock %}

{% block domain_navigation %}
Expand Down
Loading
Loading