CLAUDE: RosterLink refactor for bench players with cached player data
- Add player_positions JSONB column to roster_links (migration 006) - Add player_data JSONB column to cache name/image/headshot (migration 007) - Add is_pitcher/is_batter computed properties for two-way player support - Update lineup submission to populate RosterLink with all players + positions - Update get_bench handler to use cached data (no runtime API calls) - Add BenchPlayer type to frontend with proper filtering - Add new Lineup components: InlineSubstitutionPanel, LineupSlotRow, PositionSelector, UnifiedLineupTab - Add integration tests for get_bench_players Bench players now load instantly without API dependency, and properly filter batters vs pitchers (including CP closer position). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
64325d7163
commit
e058bc4a6c
@ -0,0 +1,33 @@
|
|||||||
|
"""add player_positions to roster_links
|
||||||
|
|
||||||
|
Revision ID: 006
|
||||||
|
Revises: 62bd3195c64c
|
||||||
|
Create Date: 2026-01-17
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '006'
|
||||||
|
down_revision: Union[str, None] = '62bd3195c64c'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add player_positions column for storing player's natural positions
|
||||||
|
# This allows the frontend to filter bench players by position type (batters vs pitchers)
|
||||||
|
# Example values: ["SS", "2B", "3B"] or ["SP", "RP"] or ["SP", "DH"] (two-way player)
|
||||||
|
op.add_column(
|
||||||
|
'roster_links',
|
||||||
|
sa.Column('player_positions', JSONB, nullable=True, server_default='[]')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('roster_links', 'player_positions')
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
"""Add player_data JSONB column to roster_links for caching player names/images.
|
||||||
|
|
||||||
|
Revision ID: 007
|
||||||
|
Revises: 006
|
||||||
|
Create Date: 2026-01-17
|
||||||
|
|
||||||
|
This stores player name, image, and headshot at lineup submission time,
|
||||||
|
eliminating the need for runtime API calls when loading bench players.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "007"
|
||||||
|
down_revision = "006"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"roster_links",
|
||||||
|
sa.Column("player_data", JSONB, nullable=True, server_default="{}")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("roster_links", "player_data")
|
||||||
@ -771,6 +771,12 @@ async def submit_lineups(game_id: str, request: SubmitLineupsRequest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BenchPlayerRequest(BaseModel):
|
||||||
|
"""Single bench player in lineup request"""
|
||||||
|
|
||||||
|
player_id: int = Field(..., description="SBA player ID")
|
||||||
|
|
||||||
|
|
||||||
class SubmitTeamLineupRequest(BaseModel):
|
class SubmitTeamLineupRequest(BaseModel):
|
||||||
"""Request model for submitting a single team's lineup"""
|
"""Request model for submitting a single team's lineup"""
|
||||||
|
|
||||||
@ -778,6 +784,9 @@ class SubmitTeamLineupRequest(BaseModel):
|
|||||||
lineup: list[LineupPlayerRequest] = Field(
|
lineup: list[LineupPlayerRequest] = Field(
|
||||||
..., min_length=9, max_length=10, description="Team's starting lineup (9-10 players)"
|
..., min_length=9, max_length=10, description="Team's starting lineup (9-10 players)"
|
||||||
)
|
)
|
||||||
|
bench: list[BenchPlayerRequest] = Field(
|
||||||
|
default_factory=list, description="Bench players (not in starting lineup)"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("lineup")
|
@field_validator("lineup")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -1062,7 +1071,49 @@ async def submit_team_lineup(game_id: str, request: SubmitTeamLineupRequest):
|
|||||||
detail=f"Lineup already submitted for team {request.team_id}"
|
detail=f"Lineup already submitted for team {request.team_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process lineup
|
# Step 1: Collect all player IDs (starters + bench) for batch API fetch
|
||||||
|
all_player_ids = [p.player_id for p in request.lineup]
|
||||||
|
all_player_ids.extend([p.player_id for p in request.bench])
|
||||||
|
|
||||||
|
# Step 2: Fetch all player data from SBA API to get positions
|
||||||
|
player_data = {}
|
||||||
|
if all_player_ids:
|
||||||
|
try:
|
||||||
|
player_data = await sba_api_client.get_players_batch(all_player_ids)
|
||||||
|
logger.info(f"Fetched {len(player_data)}/{len(all_player_ids)} players from SBA API")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch player data from SBA API: {e}")
|
||||||
|
# Continue - roster entries will have empty positions
|
||||||
|
|
||||||
|
# Step 3: Add ALL players to RosterLink with their natural positions and cached data
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
roster_count = 0
|
||||||
|
for player_id in all_player_ids:
|
||||||
|
player_positions: list[str] = []
|
||||||
|
cached_player_data: dict | None = None
|
||||||
|
|
||||||
|
if player_id in player_data:
|
||||||
|
player = player_data[player_id]
|
||||||
|
player_positions = player.get_positions()
|
||||||
|
# Cache essential player data to avoid runtime API calls
|
||||||
|
cached_player_data = {
|
||||||
|
"name": player.name,
|
||||||
|
"image": player.get_image_url(),
|
||||||
|
"headshot": player.headshot or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
await db_ops.add_sba_roster_player(
|
||||||
|
game_id=game_uuid,
|
||||||
|
player_id=player_id,
|
||||||
|
team_id=request.team_id,
|
||||||
|
player_positions=player_positions,
|
||||||
|
player_data=cached_player_data,
|
||||||
|
)
|
||||||
|
roster_count += 1
|
||||||
|
|
||||||
|
logger.info(f"Added {roster_count} players to roster for team {request.team_id}")
|
||||||
|
|
||||||
|
# Step 4: Add only STARTERS to active Lineup table
|
||||||
player_count = 0
|
player_count = 0
|
||||||
for player in request.lineup:
|
for player in request.lineup:
|
||||||
await lineup_service.add_sba_player_to_lineup(
|
await lineup_service.add_sba_player_to_lineup(
|
||||||
@ -1075,7 +1126,10 @@ async def submit_team_lineup(game_id: str, request: SubmitTeamLineupRequest):
|
|||||||
)
|
)
|
||||||
player_count += 1
|
player_count += 1
|
||||||
|
|
||||||
logger.info(f"Added {player_count} players to team {request.team_id} lineup")
|
logger.info(f"Added {player_count} starters to team {request.team_id} lineup")
|
||||||
|
|
||||||
|
# Note: Bench players are NOT added to Lineup - they're derived from
|
||||||
|
# RosterLink players not in active Lineup via get_bench_players()
|
||||||
|
|
||||||
# Load lineup from DB and cache in state_manager for subsequent checks
|
# Load lineup from DB and cache in state_manager for subsequent checks
|
||||||
team_lineup = await lineup_service.load_team_lineup_with_player_data(
|
team_lineup = await lineup_service.load_team_lineup_with_player_data(
|
||||||
|
|||||||
@ -290,12 +290,12 @@ class DatabaseOperations:
|
|||||||
position=position,
|
position=position,
|
||||||
batting_order=batting_order,
|
batting_order=batting_order,
|
||||||
is_starter=is_starter,
|
is_starter=is_starter,
|
||||||
is_active=True,
|
is_active=is_starter, # Bench players (is_starter=False) are inactive
|
||||||
)
|
)
|
||||||
session.add(lineup)
|
session.add(lineup)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await session.refresh(lineup)
|
await session.refresh(lineup)
|
||||||
logger.debug(f"Added SBA player {player_id} to lineup in game {game_id}")
|
logger.debug(f"Added SBA player {player_id} to lineup in game {game_id} (active={is_starter})")
|
||||||
return lineup
|
return lineup
|
||||||
|
|
||||||
async def get_active_lineup(self, game_id: UUID, team_id: int) -> list[Lineup]:
|
async def get_active_lineup(self, game_id: UUID, team_id: int) -> list[Lineup]:
|
||||||
@ -325,6 +325,35 @@ class DatabaseOperations:
|
|||||||
)
|
)
|
||||||
return lineups
|
return lineups
|
||||||
|
|
||||||
|
async def get_full_lineup(self, game_id: UUID, team_id: int) -> list[Lineup]:
|
||||||
|
"""
|
||||||
|
Get full lineup for team including bench players.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_id: Game identifier
|
||||||
|
team_id: Team identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of all Lineup models (active + bench), active sorted by batting order first
|
||||||
|
"""
|
||||||
|
async with self._get_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Lineup)
|
||||||
|
.where(
|
||||||
|
Lineup.game_id == game_id,
|
||||||
|
Lineup.team_id == team_id,
|
||||||
|
)
|
||||||
|
.order_by(Lineup.is_active.desc(), Lineup.batting_order)
|
||||||
|
)
|
||||||
|
lineups = list(result.scalars().all())
|
||||||
|
active_count = sum(1 for l in lineups if l.is_active)
|
||||||
|
bench_count = len(lineups) - active_count
|
||||||
|
logger.debug(
|
||||||
|
f"Retrieved {len(lineups)} lineup entries for team {team_id} "
|
||||||
|
f"({active_count} active, {bench_count} bench)"
|
||||||
|
)
|
||||||
|
return lineups
|
||||||
|
|
||||||
async def create_substitution(
|
async def create_substitution(
|
||||||
self,
|
self,
|
||||||
game_id: UUID,
|
game_id: UUID,
|
||||||
@ -640,7 +669,12 @@ class DatabaseOperations:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def add_sba_roster_player(
|
async def add_sba_roster_player(
|
||||||
self, game_id: UUID, player_id: int, team_id: int
|
self,
|
||||||
|
game_id: UUID,
|
||||||
|
player_id: int,
|
||||||
|
team_id: int,
|
||||||
|
player_positions: list[str] | None = None,
|
||||||
|
player_data: dict | None = None,
|
||||||
) -> SbaRosterLinkData:
|
) -> SbaRosterLinkData:
|
||||||
"""
|
"""
|
||||||
Add an SBA player to game roster.
|
Add an SBA player to game roster.
|
||||||
@ -649,6 +683,9 @@ class DatabaseOperations:
|
|||||||
game_id: Game identifier
|
game_id: Game identifier
|
||||||
player_id: Player identifier
|
player_id: Player identifier
|
||||||
team_id: Team identifier
|
team_id: Team identifier
|
||||||
|
player_positions: List of natural positions (e.g., ["SS", "2B", "3B"] or ["SP", "RP"])
|
||||||
|
Used for substitution UI filtering (batters vs pitchers)
|
||||||
|
player_data: Cached player info {name, image, headshot} to avoid runtime API calls
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
SbaRosterLinkData with populated id
|
SbaRosterLinkData with populated id
|
||||||
@ -658,18 +695,27 @@ class DatabaseOperations:
|
|||||||
"""
|
"""
|
||||||
async with self._get_session() as session:
|
async with self._get_session() as session:
|
||||||
roster_link = RosterLink(
|
roster_link = RosterLink(
|
||||||
game_id=game_id, player_id=player_id, team_id=team_id
|
game_id=game_id,
|
||||||
|
player_id=player_id,
|
||||||
|
team_id=team_id,
|
||||||
|
player_positions=player_positions or [],
|
||||||
|
player_data=player_data or {},
|
||||||
)
|
)
|
||||||
session.add(roster_link)
|
session.add(roster_link)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await session.refresh(roster_link)
|
await session.refresh(roster_link)
|
||||||
logger.info(f"Added SBA player {player_id} to roster for game {game_id}")
|
logger.info(
|
||||||
|
f"Added SBA player {player_id} to roster for game {game_id} "
|
||||||
|
f"(positions: {player_positions or []})"
|
||||||
|
)
|
||||||
|
|
||||||
return SbaRosterLinkData(
|
return SbaRosterLinkData(
|
||||||
id=roster_link.id,
|
id=roster_link.id,
|
||||||
game_id=roster_link.game_id,
|
game_id=roster_link.game_id,
|
||||||
player_id=roster_link.player_id,
|
player_id=roster_link.player_id,
|
||||||
team_id=roster_link.team_id,
|
team_id=roster_link.team_id,
|
||||||
|
player_positions=roster_link.player_positions or [],
|
||||||
|
player_data=roster_link.player_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_pd_roster(
|
async def get_pd_roster(
|
||||||
@ -717,7 +763,7 @@ class DatabaseOperations:
|
|||||||
team_id: Optional team filter
|
team_id: Optional team filter
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of SbaRosterLinkData
|
List of SbaRosterLinkData with player_positions
|
||||||
"""
|
"""
|
||||||
async with self._get_session() as session:
|
async with self._get_session() as session:
|
||||||
query = select(RosterLink).where(
|
query = select(RosterLink).where(
|
||||||
@ -736,10 +782,70 @@ class DatabaseOperations:
|
|||||||
game_id=link.game_id,
|
game_id=link.game_id,
|
||||||
player_id=link.player_id,
|
player_id=link.player_id,
|
||||||
team_id=link.team_id,
|
team_id=link.team_id,
|
||||||
|
player_positions=link.player_positions or [],
|
||||||
|
player_data=link.player_data,
|
||||||
)
|
)
|
||||||
for link in roster_links
|
for link in roster_links
|
||||||
]
|
]
|
||||||
|
|
||||||
|
async def get_bench_players(
|
||||||
|
self, game_id: UUID, team_id: int
|
||||||
|
) -> list[SbaRosterLinkData]:
|
||||||
|
"""
|
||||||
|
Get bench players for a team (roster players not in active lineup).
|
||||||
|
|
||||||
|
This queries RosterLink for players that are NOT in the active Lineup,
|
||||||
|
including their player_positions for UI filtering (batters vs pitchers).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_id: Game identifier
|
||||||
|
team_id: Team identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of SbaRosterLinkData for bench players with is_pitcher/is_batter computed
|
||||||
|
"""
|
||||||
|
async with self._get_session() as session:
|
||||||
|
# Get player_ids that are in the active lineup
|
||||||
|
active_lineup_query = (
|
||||||
|
select(Lineup.player_id)
|
||||||
|
.where(
|
||||||
|
Lineup.game_id == game_id,
|
||||||
|
Lineup.team_id == team_id,
|
||||||
|
Lineup.is_active == True,
|
||||||
|
Lineup.player_id.is_not(None), # SBA players
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get roster players NOT in active lineup
|
||||||
|
bench_query = (
|
||||||
|
select(RosterLink)
|
||||||
|
.where(
|
||||||
|
RosterLink.game_id == game_id,
|
||||||
|
RosterLink.team_id == team_id,
|
||||||
|
RosterLink.player_id.is_not(None), # SBA players
|
||||||
|
RosterLink.player_id.not_in(active_lineup_query),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(bench_query)
|
||||||
|
bench_links = result.scalars().all()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Retrieved {len(bench_links)} bench players for team {team_id} in game {game_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
SbaRosterLinkData(
|
||||||
|
id=link.id,
|
||||||
|
game_id=link.game_id,
|
||||||
|
player_id=link.player_id,
|
||||||
|
team_id=link.team_id,
|
||||||
|
player_positions=link.player_positions or [],
|
||||||
|
player_data=link.player_data,
|
||||||
|
)
|
||||||
|
for link in bench_links
|
||||||
|
]
|
||||||
|
|
||||||
async def remove_roster_entry(self, roster_id: int) -> None:
|
async def remove_roster_entry(self, roster_id: int) -> None:
|
||||||
"""
|
"""
|
||||||
Remove a roster entry by ID.
|
Remove a roster entry by ID.
|
||||||
|
|||||||
@ -43,6 +43,11 @@ class RosterLink(Base):
|
|||||||
SBA League: Uses player_id to track which players are rostered
|
SBA League: Uses player_id to track which players are rostered
|
||||||
|
|
||||||
Exactly one of card_id or player_id must be populated per row.
|
Exactly one of card_id or player_id must be populated per row.
|
||||||
|
|
||||||
|
The player_positions field stores the player's natural positions from the API
|
||||||
|
for use in substitution UI filtering (batters vs pitchers). This supports
|
||||||
|
two-way players like Shohei Ohtani who have both pitching and batting positions.
|
||||||
|
Example: ["SS", "2B", "3B"] or ["SP", "RP"] or ["SP", "DH"]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "roster_links"
|
__tablename__ = "roster_links"
|
||||||
@ -60,6 +65,14 @@ class RosterLink(Base):
|
|||||||
player_id = Column(Integer, nullable=True) # SBA only
|
player_id = Column(Integer, nullable=True) # SBA only
|
||||||
team_id = Column(Integer, nullable=False, index=True)
|
team_id = Column(Integer, nullable=False, index=True)
|
||||||
|
|
||||||
|
# Player's natural positions from API (for substitution UI filtering)
|
||||||
|
# Supports two-way players with both pitching and batting positions
|
||||||
|
player_positions = Column(JSONB, default=list)
|
||||||
|
|
||||||
|
# Cached player data (name, image, headshot) to avoid runtime API calls
|
||||||
|
# Populated at lineup submission time
|
||||||
|
player_data = Column(JSONB, default=dict)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
game = relationship("Game", back_populates="roster_links")
|
game = relationship("Game", back_populates="roster_links")
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ Provides league-specific type-safe models for roster operations:
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, computed_field, field_validator
|
||||||
|
|
||||||
|
|
||||||
class BaseRosterLinkData(BaseModel, ABC):
|
class BaseRosterLinkData(BaseModel, ABC):
|
||||||
@ -62,9 +62,18 @@ class SbaRosterLinkData(BaseRosterLinkData):
|
|||||||
|
|
||||||
Used for SBA league games where rosters are composed of players.
|
Used for SBA league games where rosters are composed of players.
|
||||||
Players are identified directly by player_id without a card system.
|
Players are identified directly by player_id without a card system.
|
||||||
|
|
||||||
|
The player_positions field stores the player's natural positions from the API
|
||||||
|
for use in substitution UI filtering (batters vs pitchers). This supports
|
||||||
|
two-way players like Shohei Ohtani who have both pitching and batting positions.
|
||||||
|
|
||||||
|
The player_data field caches essential SbaPlayer fields (name, image, headshot)
|
||||||
|
to avoid runtime API calls when loading bench players.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
player_id: int
|
player_id: int
|
||||||
|
player_positions: list[str] = []
|
||||||
|
player_data: dict | None = None # Cached SbaPlayer fields: {name, image, headshot}
|
||||||
|
|
||||||
@field_validator("player_id")
|
@field_validator("player_id")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -79,6 +88,34 @@ class SbaRosterLinkData(BaseRosterLinkData):
|
|||||||
def get_entity_type(self) -> str:
|
def get_entity_type(self) -> str:
|
||||||
return "player"
|
return "player"
|
||||||
|
|
||||||
|
# Pitcher positions used for filtering
|
||||||
|
# CP = Closer Pitcher (alternate notation for CL)
|
||||||
|
_PITCHER_POSITIONS: set[str] = {"P", "SP", "RP", "CL", "CP"}
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def is_pitcher(self) -> bool:
|
||||||
|
"""True if player has any pitching position (supports two-way players)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- ["SP"] -> True
|
||||||
|
- ["SP", "DH"] -> True (two-way player like Ohtani)
|
||||||
|
- ["CF", "DH"] -> False
|
||||||
|
"""
|
||||||
|
return any(pos in self._PITCHER_POSITIONS for pos in self.player_positions)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def is_batter(self) -> bool:
|
||||||
|
"""True if player has any non-pitching position (supports two-way players)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- ["CF", "DH"] -> True
|
||||||
|
- ["SP", "DH"] -> True (two-way player like Ohtani)
|
||||||
|
- ["SP"] -> False (pitcher-only)
|
||||||
|
"""
|
||||||
|
return any(pos not in self._PITCHER_POSITIONS for pos in self.player_positions)
|
||||||
|
|
||||||
|
|
||||||
class RosterLinkCreate(BaseModel):
|
class RosterLinkCreate(BaseModel):
|
||||||
"""Request model for creating a roster link"""
|
"""Request model for creating a roster link"""
|
||||||
@ -87,6 +124,7 @@ class RosterLinkCreate(BaseModel):
|
|||||||
team_id: int
|
team_id: int
|
||||||
card_id: int | None = None
|
card_id: int | None = None
|
||||||
player_id: int | None = None
|
player_id: int | None = None
|
||||||
|
player_positions: list[str] = [] # Natural positions for substitution filtering
|
||||||
|
|
||||||
@field_validator("team_id")
|
@field_validator("team_id")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -116,5 +154,8 @@ class RosterLinkCreate(BaseModel):
|
|||||||
if self.player_id is None:
|
if self.player_id is None:
|
||||||
raise ValueError("player_id required for SBA roster")
|
raise ValueError("player_id required for SBA roster")
|
||||||
return SbaRosterLinkData(
|
return SbaRosterLinkData(
|
||||||
game_id=self.game_id, team_id=self.team_id, player_id=self.player_id
|
game_id=self.game_id,
|
||||||
|
team_id=self.team_id,
|
||||||
|
player_id=self.player_id,
|
||||||
|
player_positions=self.player_positions,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1394,6 +1394,116 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
|||||||
sid, "error", {"message": "Invalid lineup request"}
|
sid, "error", {"message": "Invalid lineup request"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def get_bench(sid, data):
|
||||||
|
"""
|
||||||
|
Get bench players for a team (roster players not in active lineup).
|
||||||
|
|
||||||
|
This queries RosterLink for players that are NOT in the active Lineup,
|
||||||
|
including their natural positions for UI filtering (batters vs pitchers).
|
||||||
|
Supports two-way players with is_pitcher and is_batter computed properties.
|
||||||
|
|
||||||
|
Event data:
|
||||||
|
game_id: UUID of the game
|
||||||
|
team_id: int - team to get bench for
|
||||||
|
|
||||||
|
Emits:
|
||||||
|
bench_data: To requester with bench players including:
|
||||||
|
- player_positions: list of natural positions (e.g., ["SS", "2B"])
|
||||||
|
- is_pitcher: true if player has pitching positions
|
||||||
|
- is_batter: true if player has batting positions
|
||||||
|
error: To requester if validation fails
|
||||||
|
"""
|
||||||
|
await manager.update_activity(sid)
|
||||||
|
|
||||||
|
if not await rate_limiter.check_websocket_limit(sid):
|
||||||
|
await manager.emit_to_user(
|
||||||
|
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
game_id_str = data.get("game_id")
|
||||||
|
if not game_id_str:
|
||||||
|
await manager.emit_to_user(sid, "error", {"message": "Missing game_id"})
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
game_id = UUID(game_id_str)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
await manager.emit_to_user(
|
||||||
|
sid, "error", {"message": "Invalid game_id format"}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
team_id = data.get("team_id")
|
||||||
|
if team_id is None:
|
||||||
|
await manager.emit_to_user(sid, "error", {"message": "Missing team_id"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Query bench players from RosterLink (roster players not in active lineup)
|
||||||
|
# Player data (name, image, headshot) is cached in RosterLink - no API call needed
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
bench_roster = await db_ops.get_bench_players(game_id, team_id)
|
||||||
|
|
||||||
|
if bench_roster:
|
||||||
|
bench_players = []
|
||||||
|
for roster in bench_roster:
|
||||||
|
# Use cached player_data from RosterLink (populated at lineup submission)
|
||||||
|
pdata = roster.player_data or {}
|
||||||
|
player_name = pdata.get("name", f"Player #{roster.player_id}")
|
||||||
|
player_image = pdata.get("image", "")
|
||||||
|
player_headshot = pdata.get("headshot", "")
|
||||||
|
|
||||||
|
bench_players.append({
|
||||||
|
"roster_id": roster.id,
|
||||||
|
"player_id": roster.player_id,
|
||||||
|
"player_positions": roster.player_positions,
|
||||||
|
"is_pitcher": roster.is_pitcher,
|
||||||
|
"is_batter": roster.is_batter,
|
||||||
|
"player": {
|
||||||
|
"id": roster.player_id,
|
||||||
|
"name": player_name,
|
||||||
|
"image": player_image,
|
||||||
|
"headshot": player_headshot,
|
||||||
|
# Include positions for frontend filtering (legacy support)
|
||||||
|
"pos_1": roster.player_positions[0] if roster.player_positions else None,
|
||||||
|
"pos_2": roster.player_positions[1] if len(roster.player_positions) > 1 else None,
|
||||||
|
"pos_3": roster.player_positions[2] if len(roster.player_positions) > 2 else None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await manager.emit_to_user(
|
||||||
|
sid,
|
||||||
|
"bench_data",
|
||||||
|
{
|
||||||
|
"game_id": str(game_id),
|
||||||
|
"team_id": team_id,
|
||||||
|
"players": bench_players,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Bench data sent for game {game_id}, team {team_id}: {len(bench_players)} players"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await manager.emit_to_user(
|
||||||
|
sid,
|
||||||
|
"bench_data",
|
||||||
|
{"game_id": str(game_id), "team_id": team_id, "players": []},
|
||||||
|
)
|
||||||
|
logger.info(f"No bench players found for game {game_id}, team {team_id}")
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Database error in get_bench: {e}")
|
||||||
|
await manager.emit_to_user(
|
||||||
|
sid, "error", {"message": "Database error - please retry"}
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.warning(f"Invalid data in get_bench request: {e}")
|
||||||
|
await manager.emit_to_user(
|
||||||
|
sid, "error", {"message": "Invalid bench request"}
|
||||||
|
)
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
async def submit_defensive_decision(sid, data):
|
async def submit_defensive_decision(sid, data):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -736,6 +736,141 @@ class TestDatabaseOperationsRoster:
|
|||||||
roster = await db_ops.get_sba_roster(game_id)
|
roster = await db_ops.get_sba_roster(game_id)
|
||||||
assert len(roster) == 0
|
assert len(roster) == 0
|
||||||
|
|
||||||
|
async def test_get_bench_players(self, db_ops, db_session):
|
||||||
|
"""
|
||||||
|
Test get_bench_players returns roster players NOT in active lineup.
|
||||||
|
|
||||||
|
This verifies the RosterLink refactor where:
|
||||||
|
- RosterLink contains ALL eligible players with player_positions
|
||||||
|
- Lineup contains only ACTIVE players
|
||||||
|
- Bench = RosterLink players NOT IN Lineup
|
||||||
|
|
||||||
|
Also tests computed is_pitcher/is_batter properties.
|
||||||
|
"""
|
||||||
|
game_id = uuid4()
|
||||||
|
team_id = 10
|
||||||
|
|
||||||
|
# Create game
|
||||||
|
await db_ops.create_game(
|
||||||
|
game_id=game_id,
|
||||||
|
league_id="sba",
|
||||||
|
home_team_id=team_id,
|
||||||
|
away_team_id=20,
|
||||||
|
game_mode="friendly",
|
||||||
|
visibility="public"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add players to RosterLink with player_positions
|
||||||
|
# Player 101: Pitcher only
|
||||||
|
await db_ops.add_sba_roster_player(
|
||||||
|
game_id=game_id,
|
||||||
|
player_id=101,
|
||||||
|
team_id=team_id,
|
||||||
|
player_positions=["SP", "RP"]
|
||||||
|
)
|
||||||
|
# Player 102: Batter only (shortstop)
|
||||||
|
await db_ops.add_sba_roster_player(
|
||||||
|
game_id=game_id,
|
||||||
|
player_id=102,
|
||||||
|
team_id=team_id,
|
||||||
|
player_positions=["SS", "2B"]
|
||||||
|
)
|
||||||
|
# Player 103: Two-way player (pitcher and DH)
|
||||||
|
await db_ops.add_sba_roster_player(
|
||||||
|
game_id=game_id,
|
||||||
|
player_id=103,
|
||||||
|
team_id=team_id,
|
||||||
|
player_positions=["SP", "DH"]
|
||||||
|
)
|
||||||
|
# Player 104: Outfielder (will be in active lineup)
|
||||||
|
await db_ops.add_sba_roster_player(
|
||||||
|
game_id=game_id,
|
||||||
|
player_id=104,
|
||||||
|
team_id=team_id,
|
||||||
|
player_positions=["CF", "RF"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add player 104 to ACTIVE lineup (not bench)
|
||||||
|
await db_ops.add_sba_lineup_player(
|
||||||
|
game_id=game_id,
|
||||||
|
team_id=team_id,
|
||||||
|
player_id=104,
|
||||||
|
position="CF",
|
||||||
|
batting_order=1,
|
||||||
|
is_starter=True
|
||||||
|
)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
# Get bench players (should be 101, 102, 103 - NOT 104)
|
||||||
|
bench = await db_ops.get_bench_players(game_id, team_id)
|
||||||
|
|
||||||
|
# Verify count
|
||||||
|
assert len(bench) == 3
|
||||||
|
|
||||||
|
# Verify player IDs (104 should NOT be in bench)
|
||||||
|
bench_player_ids = {p.player_id for p in bench}
|
||||||
|
assert bench_player_ids == {101, 102, 103}
|
||||||
|
assert 104 not in bench_player_ids
|
||||||
|
|
||||||
|
# Verify computed properties for each player
|
||||||
|
bench_by_id = {p.player_id: p for p in bench}
|
||||||
|
|
||||||
|
# Player 101: Pitcher only
|
||||||
|
assert bench_by_id[101].is_pitcher is True
|
||||||
|
assert bench_by_id[101].is_batter is False
|
||||||
|
assert bench_by_id[101].player_positions == ["SP", "RP"]
|
||||||
|
|
||||||
|
# Player 102: Batter only
|
||||||
|
assert bench_by_id[102].is_pitcher is False
|
||||||
|
assert bench_by_id[102].is_batter is True
|
||||||
|
assert bench_by_id[102].player_positions == ["SS", "2B"]
|
||||||
|
|
||||||
|
# Player 103: Two-way player (BOTH is_pitcher AND is_batter)
|
||||||
|
assert bench_by_id[103].is_pitcher is True
|
||||||
|
assert bench_by_id[103].is_batter is True
|
||||||
|
assert bench_by_id[103].player_positions == ["SP", "DH"]
|
||||||
|
|
||||||
|
async def test_get_bench_players_empty(self, db_ops, db_session):
|
||||||
|
"""
|
||||||
|
Test get_bench_players returns empty list when all roster players are in lineup.
|
||||||
|
"""
|
||||||
|
game_id = uuid4()
|
||||||
|
team_id = 10
|
||||||
|
|
||||||
|
# Create game
|
||||||
|
await db_ops.create_game(
|
||||||
|
game_id=game_id,
|
||||||
|
league_id="sba",
|
||||||
|
home_team_id=team_id,
|
||||||
|
away_team_id=20,
|
||||||
|
game_mode="friendly",
|
||||||
|
visibility="public"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add player to roster
|
||||||
|
await db_ops.add_sba_roster_player(
|
||||||
|
game_id=game_id,
|
||||||
|
player_id=101,
|
||||||
|
team_id=team_id,
|
||||||
|
player_positions=["CF"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add same player to active lineup
|
||||||
|
await db_ops.add_sba_lineup_player(
|
||||||
|
game_id=game_id,
|
||||||
|
team_id=team_id,
|
||||||
|
player_id=101,
|
||||||
|
position="CF",
|
||||||
|
batting_order=1,
|
||||||
|
is_starter=True
|
||||||
|
)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
# Get bench players (should be empty)
|
||||||
|
bench = await db_ops.get_bench_players(game_id, team_id)
|
||||||
|
|
||||||
|
assert len(bench) == 0
|
||||||
|
|
||||||
|
|
||||||
class TestDatabaseOperationsRollback:
|
class TestDatabaseOperationsRollback:
|
||||||
"""Tests for database rollback operations (delete_plays_after, etc.)"""
|
"""Tests for database rollback operations (delete_plays_after, etc.)"""
|
||||||
|
|||||||
344
frontend-sba/components/Lineup/InlineSubstitutionPanel.vue
Normal file
344
frontend-sba/components/Lineup/InlineSubstitutionPanel.vue
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<!-- Position Selector -->
|
||||||
|
<PositionSelector
|
||||||
|
v-if="showPositionSelector"
|
||||||
|
:mode="substitutionType"
|
||||||
|
v-model="selectedPosition"
|
||||||
|
:current-position="currentPosition"
|
||||||
|
:label="positionLabel"
|
||||||
|
:required="positionRequired"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Available Players Section -->
|
||||||
|
<div v-if="!isPositionChangeOnly">
|
||||||
|
<div class="text-xs text-gray-400 mb-2">{{ playersLabel }}</div>
|
||||||
|
|
||||||
|
<!-- No players message -->
|
||||||
|
<div v-if="availablePlayers.length === 0" class="text-center py-6 text-gray-500 text-sm">
|
||||||
|
No players available
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player Grid -->
|
||||||
|
<div v-else class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="benchPlayer in availablePlayers"
|
||||||
|
:key="benchPlayer.roster_id"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg p-2.5 text-left transition-all select-none touch-manipulation',
|
||||||
|
selectedPlayerId === benchPlayer.player_id
|
||||||
|
? 'bg-green-900/40 border-green-600/50 border ring-2 ring-green-500/30'
|
||||||
|
: 'bg-gray-700/60 hover:bg-green-900/40 border border-transparent hover:border-green-600/50'
|
||||||
|
]"
|
||||||
|
@click="selectPlayer(benchPlayer)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold',
|
||||||
|
selectedPlayerId === benchPlayer.player_id
|
||||||
|
? 'bg-gradient-to-br from-green-700 to-green-800'
|
||||||
|
: 'bg-gradient-to-br from-gray-600 to-gray-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<template v-if="selectedPlayerId === benchPlayer.player_id">
|
||||||
|
✓
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ getInitials(benchPlayer.player.name) }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-sm">{{ benchPlayer.player.name }}</div>
|
||||||
|
<div :class="[
|
||||||
|
'text-[10px]',
|
||||||
|
selectedPlayerId === benchPlayer.player_id ? 'text-green-400' : 'text-gray-400'
|
||||||
|
]">
|
||||||
|
{{ formatPositions(benchPlayer) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Emergency Players (Pitchers for defensive subs) -->
|
||||||
|
<div v-if="showEmergencySection && emergencyPlayers.length > 0">
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center justify-between text-xs text-gray-500 hover:text-gray-400 py-2 border-t border-gray-700/50 transition-colors select-none"
|
||||||
|
@click="showEmergency = !showEmergency"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span class="mr-1">{{ showEmergency ? '▼' : '▶' }}</span>
|
||||||
|
Show Pitchers
|
||||||
|
<span class="text-amber-500">(emergency)</span>
|
||||||
|
</span>
|
||||||
|
<span class="bg-gray-700 px-1.5 py-0.5 rounded text-gray-400">
|
||||||
|
{{ emergencyPlayers.length }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showEmergency" class="grid grid-cols-2 gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
v-for="benchPlayer in emergencyPlayers"
|
||||||
|
:key="benchPlayer.roster_id"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg p-2.5 text-left transition-all select-none touch-manipulation',
|
||||||
|
selectedPlayerId === benchPlayer.player_id
|
||||||
|
? 'bg-amber-900/40 border-amber-600/50 border'
|
||||||
|
: 'bg-amber-900/20 hover:bg-amber-900/40 border border-amber-700/30'
|
||||||
|
]"
|
||||||
|
@click="selectPlayer(benchPlayer)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-amber-700 to-amber-800 flex items-center justify-center text-xs font-bold">
|
||||||
|
{{ getInitials(benchPlayer.player.name) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-sm text-amber-200">{{ benchPlayer.player.name }}</div>
|
||||||
|
<div class="text-[10px] text-amber-400">{{ formatPositions(benchPlayer) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Position Change Confirm Button -->
|
||||||
|
<button
|
||||||
|
v-if="isPositionChangeOnly"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
:class="[
|
||||||
|
'w-full py-2.5 font-semibold rounded-lg transition-colors select-none touch-manipulation',
|
||||||
|
canSubmit
|
||||||
|
? 'bg-blue-600 hover:bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||||
|
]"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
Confirm Position Change
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Substitution Submit (auto-submits when player selected for non-defensive) -->
|
||||||
|
<div
|
||||||
|
v-if="!isPositionChangeOnly && substitutionType === 'defensive_replacement' && selectedPlayerId"
|
||||||
|
class="pt-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
:class="[
|
||||||
|
'w-full py-2.5 font-semibold rounded-lg transition-colors select-none touch-manipulation',
|
||||||
|
canSubmit
|
||||||
|
? 'bg-green-600 hover:bg-green-500 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||||
|
]"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
Confirm Substitution
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import type { BenchPlayer } from '~/types'
|
||||||
|
import PositionSelector from './PositionSelector.vue'
|
||||||
|
|
||||||
|
type SubstitutionType = 'pinch_hitter' | 'pinch_runner' | 'defensive_replacement' | 'relief_pitcher' | 'position_change'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
substitutionType: SubstitutionType
|
||||||
|
benchPlayers: BenchPlayer[]
|
||||||
|
currentPosition?: string | null
|
||||||
|
teamId: number
|
||||||
|
playerOutLineupId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
currentPosition: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [payload: {
|
||||||
|
playerOutLineupId: number
|
||||||
|
playerInCardId?: number
|
||||||
|
newPosition: string
|
||||||
|
teamId: number
|
||||||
|
type: SubstitutionType
|
||||||
|
}]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const selectedPosition = ref<string | null>(getDefaultPosition())
|
||||||
|
const selectedPlayerId = ref<number | null>(null)
|
||||||
|
const showEmergency = ref(false)
|
||||||
|
|
||||||
|
// Helper to get default position based on substitution type
|
||||||
|
function getDefaultPosition(): string | null {
|
||||||
|
switch (props.substitutionType) {
|
||||||
|
case 'pinch_hitter':
|
||||||
|
return 'PH'
|
||||||
|
case 'pinch_runner':
|
||||||
|
return 'PR'
|
||||||
|
case 'relief_pitcher':
|
||||||
|
return 'P'
|
||||||
|
case 'defensive_replacement':
|
||||||
|
return null // Required selection
|
||||||
|
case 'position_change':
|
||||||
|
return null
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this a position change only (no player selection)?
|
||||||
|
const isPositionChangeOnly = computed(() => props.substitutionType === 'position_change')
|
||||||
|
|
||||||
|
// Should we show position selector?
|
||||||
|
const showPositionSelector = computed(() => {
|
||||||
|
// Always show for position change
|
||||||
|
if (props.substitutionType === 'position_change') return true
|
||||||
|
// Don't show for relief pitcher (fixed to P)
|
||||||
|
if (props.substitutionType === 'relief_pitcher') return false
|
||||||
|
// Show for everything else
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Position selector label
|
||||||
|
const positionLabel = computed(() => {
|
||||||
|
if (props.substitutionType === 'position_change') {
|
||||||
|
return 'Change position to:'
|
||||||
|
}
|
||||||
|
return 'Position for new player:'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Is position required?
|
||||||
|
const positionRequired = computed(() => {
|
||||||
|
return props.substitutionType === 'defensive_replacement'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Players label
|
||||||
|
const playersLabel = computed(() => {
|
||||||
|
switch (props.substitutionType) {
|
||||||
|
case 'relief_pitcher':
|
||||||
|
return 'Available Relievers:'
|
||||||
|
case 'defensive_replacement':
|
||||||
|
return 'Available Position Players:'
|
||||||
|
case 'pinch_hitter':
|
||||||
|
return 'Available Batters:'
|
||||||
|
case 'pinch_runner':
|
||||||
|
return 'Available Runners:'
|
||||||
|
default:
|
||||||
|
return 'Available Players:'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show emergency section for defensive replacements and pinch hitters
|
||||||
|
const showEmergencySection = computed(() => {
|
||||||
|
return props.substitutionType === 'defensive_replacement' ||
|
||||||
|
props.substitutionType === 'pinch_hitter'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter available players based on substitution type
|
||||||
|
// Uses is_pitcher/is_batter computed properties from backend
|
||||||
|
// Supports two-way players (is_pitcher=true AND is_batter=true)
|
||||||
|
const availablePlayers = computed(() => {
|
||||||
|
switch (props.substitutionType) {
|
||||||
|
case 'relief_pitcher':
|
||||||
|
// Only show players with pitching positions
|
||||||
|
return props.benchPlayers.filter(p => p.is_pitcher)
|
||||||
|
case 'defensive_replacement':
|
||||||
|
case 'pinch_hitter':
|
||||||
|
// Show players with batting positions (includes two-way players)
|
||||||
|
return props.benchPlayers.filter(p => p.is_batter)
|
||||||
|
default:
|
||||||
|
// Show all bench players
|
||||||
|
return props.benchPlayers
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emergency players (pitcher-only players for pinch hitters/defensive subs)
|
||||||
|
// These are pitchers who DON'T have batting positions (not two-way players)
|
||||||
|
const emergencyPlayers = computed(() => {
|
||||||
|
if (props.substitutionType !== 'defensive_replacement' &&
|
||||||
|
props.substitutionType !== 'pinch_hitter') return []
|
||||||
|
// Show pitcher-only players (is_pitcher=true but is_batter=false)
|
||||||
|
return props.benchPlayers.filter(p => p.is_pitcher && !p.is_batter)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Can submit the substitution?
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
if (props.substitutionType === 'position_change') {
|
||||||
|
return selectedPosition.value !== null && selectedPosition.value !== props.currentPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.substitutionType === 'defensive_replacement') {
|
||||||
|
return selectedPosition.value !== null && selectedPlayerId.value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
// For PH/PR/relief, position has default, just need player
|
||||||
|
return selectedPlayerId.value !== null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get player initials
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
const parts = name.split(' ')
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return parts[0][0] + parts[parts.length - 1][0]
|
||||||
|
}
|
||||||
|
return name.substring(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format positions for display
|
||||||
|
// Uses player_positions array from backend (populated from RosterLink)
|
||||||
|
function formatPositions(benchPlayer: BenchPlayer): string {
|
||||||
|
if (benchPlayer.player_positions && benchPlayer.player_positions.length > 0) {
|
||||||
|
return benchPlayer.player_positions.slice(0, 3).join(', ')
|
||||||
|
}
|
||||||
|
return 'N/A'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select a player
|
||||||
|
function selectPlayer(benchPlayer: BenchPlayer) {
|
||||||
|
selectedPlayerId.value = benchPlayer.player_id
|
||||||
|
|
||||||
|
// For non-defensive subs with default position, auto-submit
|
||||||
|
if (props.substitutionType !== 'defensive_replacement' &&
|
||||||
|
props.substitutionType !== 'position_change' &&
|
||||||
|
selectedPosition.value) {
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit the substitution
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
|
||||||
|
const payload: {
|
||||||
|
playerOutLineupId: number
|
||||||
|
playerInCardId?: number
|
||||||
|
newPosition: string
|
||||||
|
teamId: number
|
||||||
|
type: SubstitutionType
|
||||||
|
} = {
|
||||||
|
playerOutLineupId: props.playerOutLineupId,
|
||||||
|
newPosition: selectedPosition.value!,
|
||||||
|
teamId: props.teamId,
|
||||||
|
type: props.substitutionType,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include player ID for actual substitutions (not position changes)
|
||||||
|
if (!isPositionChangeOnly.value && selectedPlayerId.value) {
|
||||||
|
payload.playerInCardId = selectedPlayerId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset position when substitution type changes
|
||||||
|
watch(() => props.substitutionType, () => {
|
||||||
|
selectedPosition.value = getDefaultPosition()
|
||||||
|
selectedPlayerId.value = null
|
||||||
|
showEmergency.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
205
frontend-sba/components/Lineup/LineupSlotRow.vue
Normal file
205
frontend-sba/components/Lineup/LineupSlotRow.vue
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border mb-1.5 transition-all',
|
||||||
|
containerClass
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- Slot Header Row -->
|
||||||
|
<div class="flex items-center gap-3 p-2.5">
|
||||||
|
<!-- Order Number / Position Badge -->
|
||||||
|
<div :class="['w-8 h-8 rounded-lg flex items-center justify-center', badgeClass]">
|
||||||
|
<span class="text-sm font-bold">{{ displayBadge }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player Avatar -->
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold',
|
||||||
|
avatarClass
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="player.player.headshot"
|
||||||
|
:src="player.player.headshot"
|
||||||
|
:alt="player.player.name"
|
||||||
|
class="w-full h-full rounded-full object-cover"
|
||||||
|
>
|
||||||
|
<span v-else>{{ initials }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player Info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-sm truncate">{{ player.player.name }}</div>
|
||||||
|
<div :class="['text-xs', subtextClass]">
|
||||||
|
{{ player.position }}
|
||||||
|
<template v-if="statusText">
|
||||||
|
<span class="mx-1">{{ statusText }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<template v-if="showActions && !isExpanded">
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-xs font-medium bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors select-none touch-manipulation"
|
||||||
|
@click="$emit('substitute')"
|
||||||
|
>
|
||||||
|
{{ substituteLabel }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="showPositionChange"
|
||||||
|
class="px-2 py-1.5 text-xs font-medium bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors select-none touch-manipulation"
|
||||||
|
title="Change Position"
|
||||||
|
@click="$emit('changePosition')"
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Cancel Button (when expanded) -->
|
||||||
|
<button
|
||||||
|
v-if="isExpanded"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium bg-red-900/50 hover:bg-red-900/70 text-red-300 rounded-lg transition-colors select-none touch-manipulation"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slot for expanded content (accordion) -->
|
||||||
|
<div
|
||||||
|
v-if="isExpanded"
|
||||||
|
class="border-t border-gray-700/50"
|
||||||
|
>
|
||||||
|
<slot name="expanded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Lineup } from '~/types'
|
||||||
|
|
||||||
|
type SlotState = 'normal' | 'at_bat' | 'on_base' | 'expanded' | 'position_change'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
player: Lineup
|
||||||
|
battingOrder?: number | null
|
||||||
|
state?: SlotState
|
||||||
|
isExpanded?: boolean
|
||||||
|
showActions?: boolean
|
||||||
|
showPositionChange?: boolean
|
||||||
|
substituteLabel?: string
|
||||||
|
isPitcher?: boolean
|
||||||
|
basePosition?: '1B' | '2B' | '3B' | null // For runners on base display
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
battingOrder: null,
|
||||||
|
state: 'normal',
|
||||||
|
isExpanded: false,
|
||||||
|
showActions: true,
|
||||||
|
showPositionChange: true,
|
||||||
|
substituteLabel: 'Substitute',
|
||||||
|
isPitcher: false,
|
||||||
|
basePosition: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
substitute: []
|
||||||
|
changePosition: []
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Compute initials for avatar fallback
|
||||||
|
const initials = computed(() => {
|
||||||
|
const parts = props.player.player.name.split(' ')
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return parts[0][0] + parts[parts.length - 1][0]
|
||||||
|
}
|
||||||
|
return props.player.player.name.substring(0, 2).toUpperCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Display badge (order number or base position)
|
||||||
|
const displayBadge = computed(() => {
|
||||||
|
if (props.basePosition) {
|
||||||
|
return props.basePosition
|
||||||
|
}
|
||||||
|
if (props.isPitcher) {
|
||||||
|
return 'P'
|
||||||
|
}
|
||||||
|
return props.battingOrder ?? props.player.batting_order ?? '?'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Container styling based on state
|
||||||
|
const containerClass = computed(() => {
|
||||||
|
if (props.isExpanded) {
|
||||||
|
if (props.state === 'position_change') {
|
||||||
|
return 'bg-gray-800/60 border-blue-500/50 ring-2 ring-blue-500/30'
|
||||||
|
}
|
||||||
|
return 'bg-gray-800/60 border-amber-500/50 ring-2 ring-amber-500/30'
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (props.state) {
|
||||||
|
case 'at_bat':
|
||||||
|
return 'bg-gray-800/60 border-amber-500/50 ring-2 ring-amber-500/30'
|
||||||
|
case 'on_base':
|
||||||
|
return 'bg-gray-800/60 border-green-700/50'
|
||||||
|
default:
|
||||||
|
return 'bg-gray-800/60 border-gray-700/50'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Badge styling
|
||||||
|
const badgeClass = computed(() => {
|
||||||
|
if (props.basePosition) {
|
||||||
|
return 'bg-green-800/50 text-green-400'
|
||||||
|
}
|
||||||
|
if (props.isPitcher) {
|
||||||
|
return 'bg-blue-900/50 text-blue-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (props.state) {
|
||||||
|
case 'at_bat':
|
||||||
|
return 'bg-amber-900/50 text-amber-400'
|
||||||
|
default:
|
||||||
|
return 'bg-gray-700/50 text-gray-400'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Avatar styling
|
||||||
|
const avatarClass = computed(() => {
|
||||||
|
switch (props.state) {
|
||||||
|
case 'at_bat':
|
||||||
|
return 'bg-gradient-to-br from-amber-700 to-amber-800'
|
||||||
|
case 'on_base':
|
||||||
|
return 'bg-gradient-to-br from-green-700 to-green-800 ring-2 ring-green-500/50'
|
||||||
|
default:
|
||||||
|
return 'bg-gradient-to-br from-gray-600 to-gray-700'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subtext styling
|
||||||
|
const subtextClass = computed(() => {
|
||||||
|
switch (props.state) {
|
||||||
|
case 'at_bat':
|
||||||
|
return 'text-amber-400'
|
||||||
|
case 'on_base':
|
||||||
|
return 'text-green-400'
|
||||||
|
default:
|
||||||
|
return 'text-gray-400'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Status text (AT BAT, On 1st, etc.)
|
||||||
|
const statusText = computed(() => {
|
||||||
|
if (props.state === 'at_bat') {
|
||||||
|
return 'AT BAT'
|
||||||
|
}
|
||||||
|
if (props.basePosition) {
|
||||||
|
return `On ${props.basePosition}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
93
frontend-sba/components/Lineup/PositionSelector.vue
Normal file
93
frontend-sba/components/Lineup/PositionSelector.vue
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div class="position-selector">
|
||||||
|
<div class="text-xs text-gray-400 mb-2">
|
||||||
|
{{ label }}
|
||||||
|
<span v-if="required" class="text-red-400">(required)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="pos in displayPositions"
|
||||||
|
:key="pos"
|
||||||
|
:class="[
|
||||||
|
'px-3 py-2 text-xs font-bold rounded-lg transition-colors select-none touch-manipulation',
|
||||||
|
getPositionClass(pos)
|
||||||
|
]"
|
||||||
|
:disabled="isDisabled(pos)"
|
||||||
|
@click="selectPosition(pos)"
|
||||||
|
>
|
||||||
|
{{ pos }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
type SubstitutionMode = 'pinch_hitter' | 'pinch_runner' | 'defensive_replacement' | 'relief_pitcher' | 'position_change'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: SubstitutionMode
|
||||||
|
modelValue: string | null
|
||||||
|
currentPosition?: string | null
|
||||||
|
label?: string
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
currentPosition: null,
|
||||||
|
label: 'Position for new player:',
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Standard field positions
|
||||||
|
const FIELD_POSITIONS = ['C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] as const
|
||||||
|
|
||||||
|
// Compute display positions based on mode
|
||||||
|
const displayPositions = computed(() => {
|
||||||
|
switch (props.mode) {
|
||||||
|
case 'pinch_hitter':
|
||||||
|
return ['PH', ...FIELD_POSITIONS]
|
||||||
|
case 'pinch_runner':
|
||||||
|
return ['PR', ...FIELD_POSITIONS]
|
||||||
|
case 'defensive_replacement':
|
||||||
|
case 'position_change':
|
||||||
|
return FIELD_POSITIONS
|
||||||
|
case 'relief_pitcher':
|
||||||
|
return ['P']
|
||||||
|
default:
|
||||||
|
return FIELD_POSITIONS
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if a position should be disabled
|
||||||
|
const isDisabled = (pos: string): boolean => {
|
||||||
|
// For position change, disable the current position
|
||||||
|
if (props.mode === 'position_change' && pos === props.currentPosition) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get CSS class for position button
|
||||||
|
const getPositionClass = (pos: string): string => {
|
||||||
|
if (isDisabled(pos)) {
|
||||||
|
return 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
||||||
|
}
|
||||||
|
if (props.modelValue === pos) {
|
||||||
|
return 'bg-blue-600 text-white'
|
||||||
|
}
|
||||||
|
return 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle position selection
|
||||||
|
const selectPosition = (pos: string) => {
|
||||||
|
if (!isDisabled(pos)) {
|
||||||
|
emit('update:modelValue', pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
426
frontend-sba/components/Lineup/UnifiedLineupTab.vue
Normal file
426
frontend-sba/components/Lineup/UnifiedLineupTab.vue
Normal file
@ -0,0 +1,426 @@
|
|||||||
|
<template>
|
||||||
|
<div class="unified-lineup-tab">
|
||||||
|
<!-- Pre-game: Show LineupBuilder -->
|
||||||
|
<template v-if="!isGameActive">
|
||||||
|
<LineupBuilder
|
||||||
|
:game-id="gameId"
|
||||||
|
:team-id="myTeamId"
|
||||||
|
@lineups-submitted="handleLineupSubmit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Mid-game: Read-only lineup with substitution actions -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Team Tabs -->
|
||||||
|
<div class="flex gap-2 mb-4 bg-gray-800/50 p-1 rounded-xl inline-flex">
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'px-4 py-2 text-sm font-semibold rounded-lg transition-colors select-none',
|
||||||
|
isViewingAwayTeam ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-700/50'
|
||||||
|
]"
|
||||||
|
@click="viewingTeamId = awayTeamId"
|
||||||
|
>
|
||||||
|
{{ awayTeamName }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'px-4 py-2 text-sm font-semibold rounded-lg transition-colors select-none',
|
||||||
|
!isViewingAwayTeam ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-700/50'
|
||||||
|
]"
|
||||||
|
@click="viewingTeamId = homeTeamId"
|
||||||
|
>
|
||||||
|
{{ homeTeamName }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Context indicator -->
|
||||||
|
<p class="text-xs text-gray-400 mb-4">
|
||||||
|
Mid-game view
|
||||||
|
<template v-if="isMyTeam">
|
||||||
|
• Your team is {{ isBatting ? 'BATTING' : 'FIELDING' }}
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Batting Order Section -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-400 mb-2 uppercase tracking-wide">Batting Order</h2>
|
||||||
|
|
||||||
|
<LineupSlotRow
|
||||||
|
v-for="slot in battingOrderSlots"
|
||||||
|
:key="slot.lineup_id"
|
||||||
|
:player="slot"
|
||||||
|
:batting-order="slot.batting_order"
|
||||||
|
:state="getSlotState(slot)"
|
||||||
|
:is-expanded="expandedSlotId === slot.lineup_id && expandedMode !== 'position_change'"
|
||||||
|
:show-actions="canSubstitute(slot)"
|
||||||
|
:show-position-change="canChangePosition(slot)"
|
||||||
|
:substitute-label="getSubstituteLabel(slot)"
|
||||||
|
@substitute="openSubstitution(slot)"
|
||||||
|
@change-position="openPositionChange(slot)"
|
||||||
|
@cancel="closeExpanded"
|
||||||
|
>
|
||||||
|
<template #expanded>
|
||||||
|
<InlineSubstitutionPanel
|
||||||
|
:substitution-type="getSubstitutionType(slot)"
|
||||||
|
:bench-players="benchPlayers"
|
||||||
|
:current-position="slot.position"
|
||||||
|
:team-id="viewingTeamId"
|
||||||
|
:player-out-lineup-id="slot.lineup_id"
|
||||||
|
@submit="handleSubstitutionSubmit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</LineupSlotRow>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Runners on Base Section (when batting) -->
|
||||||
|
<div v-if="isBatting && runnersOnBase.length > 0" class="mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-green-400 mb-2 uppercase tracking-wide flex items-center gap-2">
|
||||||
|
<span>◆</span> Runners on Base
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="bg-green-900/20 rounded-lg border border-green-700/30 p-2.5 space-y-2">
|
||||||
|
<LineupSlotRow
|
||||||
|
v-for="runner in runnersOnBase"
|
||||||
|
:key="`runner-${runner.lineup_id}`"
|
||||||
|
:player="runner"
|
||||||
|
:base-position="getBasePosition(runner)"
|
||||||
|
:state="'on_base'"
|
||||||
|
:is-expanded="expandedSlotId === runner.lineup_id && expandedMode === 'pinch_runner'"
|
||||||
|
:show-actions="canSubstitute(runner)"
|
||||||
|
:show-position-change="false"
|
||||||
|
substitute-label="Pinch Run"
|
||||||
|
@substitute="openPinchRunner(runner)"
|
||||||
|
@cancel="closeExpanded"
|
||||||
|
>
|
||||||
|
<template #expanded>
|
||||||
|
<InlineSubstitutionPanel
|
||||||
|
substitution-type="pinch_runner"
|
||||||
|
:bench-players="benchPlayers"
|
||||||
|
:current-position="runner.position"
|
||||||
|
:team-id="viewingTeamId"
|
||||||
|
:player-out-lineup-id="runner.lineup_id"
|
||||||
|
@submit="handleSubstitutionSubmit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</LineupSlotRow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pitcher Section -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-400 mb-2 uppercase tracking-wide">
|
||||||
|
{{ isStartingPitcher ? 'Starting Pitcher' : 'Current Pitcher' }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<LineupSlotRow
|
||||||
|
v-if="currentPitcherLineup"
|
||||||
|
:player="currentPitcherLineup"
|
||||||
|
:is-pitcher="true"
|
||||||
|
:state="expandedSlotId === currentPitcherLineup.lineup_id ? 'expanded' : 'normal'"
|
||||||
|
:is-expanded="expandedSlotId === currentPitcherLineup.lineup_id"
|
||||||
|
:show-actions="canChangePitcher"
|
||||||
|
:show-position-change="false"
|
||||||
|
substitute-label="Change Pitcher"
|
||||||
|
@substitute="openPitchingChange"
|
||||||
|
@cancel="closeExpanded"
|
||||||
|
>
|
||||||
|
<template #expanded>
|
||||||
|
<InlineSubstitutionPanel
|
||||||
|
substitution-type="relief_pitcher"
|
||||||
|
:bench-players="benchPlayers"
|
||||||
|
:current-position="'P'"
|
||||||
|
:team-id="viewingTeamId"
|
||||||
|
:player-out-lineup-id="currentPitcherLineup.lineup_id"
|
||||||
|
@submit="handleSubstitutionSubmit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</LineupSlotRow>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Position Change Panel (separate from substitution) -->
|
||||||
|
<div v-if="expandedMode === 'position_change' && positionChangeSlot" class="mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-blue-400 mb-2 uppercase tracking-wide">Position Change</h2>
|
||||||
|
|
||||||
|
<LineupSlotRow
|
||||||
|
:player="positionChangeSlot"
|
||||||
|
:state="'position_change'"
|
||||||
|
:is-expanded="true"
|
||||||
|
:show-actions="false"
|
||||||
|
@cancel="closeExpanded"
|
||||||
|
>
|
||||||
|
<template #expanded>
|
||||||
|
<InlineSubstitutionPanel
|
||||||
|
substitution-type="position_change"
|
||||||
|
:bench-players="[]"
|
||||||
|
:current-position="positionChangeSlot.position"
|
||||||
|
:team-id="viewingTeamId"
|
||||||
|
:player-out-lineup-id="positionChangeSlot.lineup_id"
|
||||||
|
@submit="handleSubstitutionSubmit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</LineupSlotRow>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useGameStore } from '~/store/game'
|
||||||
|
import { useGameActions } from '~/composables/useGameActions'
|
||||||
|
import type { Lineup } from '~/types'
|
||||||
|
import LineupBuilder from '~/components/Game/LineupBuilder.vue'
|
||||||
|
import LineupSlotRow from './LineupSlotRow.vue'
|
||||||
|
import InlineSubstitutionPanel from './InlineSubstitutionPanel.vue'
|
||||||
|
|
||||||
|
type ExpandedMode = 'substitution' | 'position_change' | 'pinch_runner' | null
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
gameId: string
|
||||||
|
myTeamId: number | null
|
||||||
|
homeTeamName?: string
|
||||||
|
awayTeamName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
homeTeamName: 'Home',
|
||||||
|
awayTeamName: 'Away',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
lineupsSubmitted: [result: unknown]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const { submitSubstitution, getBench } = useGameActions()
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const viewingTeamId = ref<number>(props.myTeamId ?? gameStore.gameState?.away_team_id ?? 0)
|
||||||
|
const expandedSlotId = ref<number | null>(null)
|
||||||
|
const expandedMode = ref<ExpandedMode>(null)
|
||||||
|
|
||||||
|
// Computed - Game state
|
||||||
|
const isGameActive = computed(() => gameStore.gameStatus === 'active')
|
||||||
|
const homeTeamId = computed(() => gameStore.gameState?.home_team_id ?? 0)
|
||||||
|
const awayTeamId = computed(() => gameStore.gameState?.away_team_id ?? 0)
|
||||||
|
|
||||||
|
// Computed - Team viewing
|
||||||
|
const isViewingAwayTeam = computed(() => viewingTeamId.value === awayTeamId.value)
|
||||||
|
const isMyTeam = computed(() => viewingTeamId.value === props.myTeamId)
|
||||||
|
const isBatting = computed(() => viewingTeamId.value === gameStore.battingTeamId)
|
||||||
|
const isFielding = computed(() => viewingTeamId.value === gameStore.fieldingTeamId)
|
||||||
|
|
||||||
|
// Computed - Lineups
|
||||||
|
const currentTeamLineup = computed(() => {
|
||||||
|
return isViewingAwayTeam.value
|
||||||
|
? gameStore.awayLineup
|
||||||
|
: gameStore.homeLineup
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeLineup = computed(() => {
|
||||||
|
return currentTeamLineup.value.filter(p => p.is_active)
|
||||||
|
})
|
||||||
|
|
||||||
|
const benchPlayers = computed(() => {
|
||||||
|
return isViewingAwayTeam.value
|
||||||
|
? gameStore.awayBench
|
||||||
|
: gameStore.homeBench
|
||||||
|
})
|
||||||
|
|
||||||
|
const battingOrderSlots = computed(() => {
|
||||||
|
return activeLineup.value
|
||||||
|
.filter(p => p.batting_order !== null && p.position !== 'P')
|
||||||
|
.sort((a, b) => (a.batting_order ?? 0) - (b.batting_order ?? 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed - Runners on base
|
||||||
|
const runnersOnBase = computed(() => {
|
||||||
|
const runners: Array<Lineup & { base: '1B' | '2B' | '3B' }> = []
|
||||||
|
const state = gameStore.gameState
|
||||||
|
|
||||||
|
if (!state) return runners
|
||||||
|
|
||||||
|
// Get runners from game state and find their lineup data
|
||||||
|
if (state.on_first) {
|
||||||
|
const lineup = gameStore.findPlayerInLineup(state.on_first.lineup_id)
|
||||||
|
if (lineup) runners.push({ ...lineup, base: '1B' })
|
||||||
|
}
|
||||||
|
if (state.on_second) {
|
||||||
|
const lineup = gameStore.findPlayerInLineup(state.on_second.lineup_id)
|
||||||
|
if (lineup) runners.push({ ...lineup, base: '2B' })
|
||||||
|
}
|
||||||
|
if (state.on_third) {
|
||||||
|
const lineup = gameStore.findPlayerInLineup(state.on_third.lineup_id)
|
||||||
|
if (lineup) runners.push({ ...lineup, base: '3B' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return runners
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed - Pitcher
|
||||||
|
const currentPitcherLineup = computed(() => {
|
||||||
|
// Get pitcher for the viewing team
|
||||||
|
return activeLineup.value.find(p => p.position === 'P')
|
||||||
|
})
|
||||||
|
|
||||||
|
const isStartingPitcher = computed(() => {
|
||||||
|
return currentPitcherLineup.value?.is_starter ?? true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Note: For demo/testing, allow pitcher changes for the team being viewed
|
||||||
|
const canChangePitcher = computed(() => {
|
||||||
|
return isFielding.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed - Current batter
|
||||||
|
const currentBatterLineupId = computed(() => {
|
||||||
|
return gameStore.currentBatter?.lineup_id ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed - Position change slot
|
||||||
|
const positionChangeSlot = computed(() => {
|
||||||
|
if (expandedMode.value !== 'position_change' || !expandedSlotId.value) return null
|
||||||
|
return activeLineup.value.find(p => p.lineup_id === expandedSlotId.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get slot state for display
|
||||||
|
function getSlotState(slot: Lineup): 'normal' | 'at_bat' | 'on_base' | 'expanded' {
|
||||||
|
if (expandedSlotId.value === slot.lineup_id) return 'expanded'
|
||||||
|
if (isBatting.value && slot.lineup_id === currentBatterLineupId.value) return 'at_bat'
|
||||||
|
if (runnersOnBase.value.some(r => r.lineup_id === slot.lineup_id)) return 'on_base'
|
||||||
|
return 'normal'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get base position for a runner
|
||||||
|
function getBasePosition(runner: Lineup & { base?: '1B' | '2B' | '3B' }): '1B' | '2B' | '3B' | null {
|
||||||
|
return runner.base ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can substitute at this slot?
|
||||||
|
// Note: For demo/testing, allow subs for whichever team is being viewed
|
||||||
|
// In production, could restrict to only your managed team
|
||||||
|
function canSubstitute(slot: Lineup): boolean {
|
||||||
|
if (!slot.is_active) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can change position at this slot?
|
||||||
|
// Note: For demo/testing, allow position changes for the team being viewed
|
||||||
|
function canChangePosition(slot: Lineup): boolean {
|
||||||
|
if (!slot.is_active) return false
|
||||||
|
// Can change position when that team is fielding
|
||||||
|
return isFielding.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get substitute button label
|
||||||
|
function getSubstituteLabel(slot: Lineup): string {
|
||||||
|
if (isBatting.value && slot.lineup_id === currentBatterLineupId.value) {
|
||||||
|
return 'Pinch Hit'
|
||||||
|
}
|
||||||
|
return 'Substitute'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get substitution type for a slot
|
||||||
|
function getSubstitutionType(slot: Lineup): 'pinch_hitter' | 'pinch_runner' | 'defensive_replacement' | 'relief_pitcher' {
|
||||||
|
if (slot.position === 'P') return 'relief_pitcher'
|
||||||
|
if (isBatting.value && slot.lineup_id === currentBatterLineupId.value) return 'pinch_hitter'
|
||||||
|
if (runnersOnBase.value.some(r => r.lineup_id === slot.lineup_id)) return 'pinch_runner'
|
||||||
|
return 'defensive_replacement'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open substitution panel for a slot
|
||||||
|
function openSubstitution(slot: Lineup) {
|
||||||
|
expandedSlotId.value = slot.lineup_id
|
||||||
|
expandedMode.value = 'substitution'
|
||||||
|
// Fetch bench players when opening substitution panel
|
||||||
|
getBench(viewingTeamId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open pinch runner panel
|
||||||
|
function openPinchRunner(runner: Lineup) {
|
||||||
|
expandedSlotId.value = runner.lineup_id
|
||||||
|
expandedMode.value = 'pinch_runner'
|
||||||
|
// Fetch bench players when opening substitution panel
|
||||||
|
getBench(viewingTeamId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open position change panel
|
||||||
|
function openPositionChange(slot: Lineup) {
|
||||||
|
expandedSlotId.value = slot.lineup_id
|
||||||
|
expandedMode.value = 'position_change'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open pitching change panel
|
||||||
|
function openPitchingChange() {
|
||||||
|
if (currentPitcherLineup.value) {
|
||||||
|
expandedSlotId.value = currentPitcherLineup.value.lineup_id
|
||||||
|
expandedMode.value = 'substitution'
|
||||||
|
// Fetch bench players when opening substitution panel
|
||||||
|
getBench(viewingTeamId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close expanded panel
|
||||||
|
function closeExpanded() {
|
||||||
|
expandedSlotId.value = null
|
||||||
|
expandedMode.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle substitution submit
|
||||||
|
async function handleSubstitutionSubmit(payload: {
|
||||||
|
playerOutLineupId: number
|
||||||
|
playerInCardId?: number
|
||||||
|
newPosition: string
|
||||||
|
teamId: number
|
||||||
|
type: string
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
// Map our type to backend substitution type
|
||||||
|
let backendType: 'pinch_hitter' | 'defensive_replacement' | 'pitching_change'
|
||||||
|
|
||||||
|
switch (payload.type) {
|
||||||
|
case 'pinch_hitter':
|
||||||
|
case 'pinch_runner':
|
||||||
|
backendType = 'pinch_hitter' // Backend treats PR as PH for now
|
||||||
|
break
|
||||||
|
case 'relief_pitcher':
|
||||||
|
backendType = 'pitching_change'
|
||||||
|
break
|
||||||
|
case 'position_change':
|
||||||
|
// TODO: Position change endpoint
|
||||||
|
console.log('Position change not yet implemented:', payload)
|
||||||
|
closeExpanded()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
backendType = 'defensive_replacement'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.playerInCardId) {
|
||||||
|
await submitSubstitution({
|
||||||
|
game_id: props.gameId,
|
||||||
|
type: backendType,
|
||||||
|
player_out_lineup_id: payload.playerOutLineupId,
|
||||||
|
player_in_card_id: payload.playerInCardId,
|
||||||
|
team_id: payload.teamId,
|
||||||
|
new_position: payload.newPosition,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
closeExpanded()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Substitution failed:', error)
|
||||||
|
// TODO: Show error toast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle lineup submit from LineupBuilder
|
||||||
|
function handleLineupSubmit(result: unknown) {
|
||||||
|
emit('lineupsSubmitted', result)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.unified-lineup-tab {
|
||||||
|
@apply w-full;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -15,6 +15,7 @@ import type {
|
|||||||
OffensiveDecision,
|
OffensiveDecision,
|
||||||
RollData,
|
RollData,
|
||||||
Lineup,
|
Lineup,
|
||||||
|
BenchPlayer,
|
||||||
} from '~/types'
|
} from '~/types'
|
||||||
|
|
||||||
export const useGameStore = defineStore('game', () => {
|
export const useGameStore = defineStore('game', () => {
|
||||||
@ -25,6 +26,8 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
const gameState = ref<GameState | null>(null)
|
const gameState = ref<GameState | null>(null)
|
||||||
const homeLineup = ref<Lineup[]>([])
|
const homeLineup = ref<Lineup[]>([])
|
||||||
const awayLineup = ref<Lineup[]>([])
|
const awayLineup = ref<Lineup[]>([])
|
||||||
|
const homeBench = ref<BenchPlayer[]>([])
|
||||||
|
const awayBench = ref<BenchPlayer[]>([])
|
||||||
const playHistory = ref<PlayResult[]>([])
|
const playHistory = ref<PlayResult[]>([])
|
||||||
const currentDecisionPrompt = ref<DecisionPrompt | null>(null)
|
const currentDecisionPrompt = ref<DecisionPrompt | null>(null)
|
||||||
const pendingRoll = ref<RollData | null>(null)
|
const pendingRoll = ref<RollData | null>(null)
|
||||||
@ -186,6 +189,19 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set bench players for a specific team
|
||||||
|
* Bench players come from RosterLink (players not in active lineup)
|
||||||
|
* with is_pitcher/is_batter computed properties for UI filtering
|
||||||
|
*/
|
||||||
|
function setBench(teamId: number, bench: BenchPlayer[]) {
|
||||||
|
if (teamId === gameState.value?.home_team_id) {
|
||||||
|
homeBench.value = bench
|
||||||
|
} else if (teamId === gameState.value?.away_team_id) {
|
||||||
|
awayBench.value = bench
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set play history (replaces entire array - used for initial sync)
|
* Set play history (replaces entire array - used for initial sync)
|
||||||
* O(1) operation - no deduplication needed
|
* O(1) operation - no deduplication needed
|
||||||
@ -323,6 +339,8 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
gameState.value = null
|
gameState.value = null
|
||||||
homeLineup.value = []
|
homeLineup.value = []
|
||||||
awayLineup.value = []
|
awayLineup.value = []
|
||||||
|
homeBench.value = []
|
||||||
|
awayBench.value = []
|
||||||
playHistory.value = []
|
playHistory.value = []
|
||||||
currentDecisionPrompt.value = null
|
currentDecisionPrompt.value = null
|
||||||
pendingRoll.value = null
|
pendingRoll.value = null
|
||||||
@ -373,6 +391,8 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
gameState: readonly(gameState),
|
gameState: readonly(gameState),
|
||||||
homeLineup: readonly(homeLineup),
|
homeLineup: readonly(homeLineup),
|
||||||
awayLineup: readonly(awayLineup),
|
awayLineup: readonly(awayLineup),
|
||||||
|
homeBench: readonly(homeBench),
|
||||||
|
awayBench: readonly(awayBench),
|
||||||
playHistory: readonly(playHistory),
|
playHistory: readonly(playHistory),
|
||||||
currentDecisionPrompt: readonly(currentDecisionPrompt),
|
currentDecisionPrompt: readonly(currentDecisionPrompt),
|
||||||
pendingRoll: readonly(pendingRoll),
|
pendingRoll: readonly(pendingRoll),
|
||||||
@ -421,6 +441,7 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
updateGameState,
|
updateGameState,
|
||||||
setLineups,
|
setLineups,
|
||||||
updateLineup,
|
updateLineup,
|
||||||
|
setBench,
|
||||||
setPlayHistory,
|
setPlayHistory,
|
||||||
addPlayToHistory,
|
addPlayToHistory,
|
||||||
setDecisionPrompt,
|
setDecisionPrompt,
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export type {
|
|||||||
SbaPlayer,
|
SbaPlayer,
|
||||||
Lineup,
|
Lineup,
|
||||||
TeamLineup,
|
TeamLineup,
|
||||||
|
BenchPlayer,
|
||||||
LineupDataResponse,
|
LineupDataResponse,
|
||||||
SubstitutionType,
|
SubstitutionType,
|
||||||
SubstitutionRequest,
|
SubstitutionRequest,
|
||||||
|
|||||||
@ -83,6 +83,33 @@ export interface TeamLineup {
|
|||||||
players: Lineup[]
|
players: Lineup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bench player from RosterLink (for substitutions)
|
||||||
|
* Backend: SbaRosterLinkData with computed is_pitcher/is_batter
|
||||||
|
*
|
||||||
|
* This is distinct from Lineup - it represents roster players
|
||||||
|
* not currently in the active lineup.
|
||||||
|
*/
|
||||||
|
export interface BenchPlayer {
|
||||||
|
roster_id: number
|
||||||
|
player_id: number
|
||||||
|
player_positions: string[] // Natural positions (e.g., ["SS", "2B"])
|
||||||
|
is_pitcher: boolean // True if player has pitching positions
|
||||||
|
is_batter: boolean // True if player has batting positions
|
||||||
|
|
||||||
|
// Player data (from SBA API)
|
||||||
|
player: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
image: string
|
||||||
|
headshot: string
|
||||||
|
// Legacy position fields for backwards compatibility
|
||||||
|
pos_1: string | null
|
||||||
|
pos_2: string | null
|
||||||
|
pos_3: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lineup data response from server
|
* Lineup data response from server
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user