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):
|
||||
"""Request model for submitting a single team's lineup"""
|
||||
|
||||
@ -778,6 +784,9 @@ class SubmitTeamLineupRequest(BaseModel):
|
||||
lineup: list[LineupPlayerRequest] = Field(
|
||||
..., 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")
|
||||
@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}"
|
||||
)
|
||||
|
||||
# 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
|
||||
for player in request.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
|
||||
|
||||
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
|
||||
team_lineup = await lineup_service.load_team_lineup_with_player_data(
|
||||
|
||||
@ -290,12 +290,12 @@ class DatabaseOperations:
|
||||
position=position,
|
||||
batting_order=batting_order,
|
||||
is_starter=is_starter,
|
||||
is_active=True,
|
||||
is_active=is_starter, # Bench players (is_starter=False) are inactive
|
||||
)
|
||||
session.add(lineup)
|
||||
await session.flush()
|
||||
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
|
||||
|
||||
async def get_active_lineup(self, game_id: UUID, team_id: int) -> list[Lineup]:
|
||||
@ -325,6 +325,35 @@ class DatabaseOperations:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
game_id: UUID,
|
||||
@ -640,7 +669,12 @@ class DatabaseOperations:
|
||||
)
|
||||
|
||||
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:
|
||||
"""
|
||||
Add an SBA player to game roster.
|
||||
@ -649,6 +683,9 @@ class DatabaseOperations:
|
||||
game_id: Game identifier
|
||||
player_id: Player 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:
|
||||
SbaRosterLinkData with populated id
|
||||
@ -658,18 +695,27 @@ class DatabaseOperations:
|
||||
"""
|
||||
async with self._get_session() as session:
|
||||
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)
|
||||
await session.flush()
|
||||
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(
|
||||
id=roster_link.id,
|
||||
game_id=roster_link.game_id,
|
||||
player_id=roster_link.player_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(
|
||||
@ -717,7 +763,7 @@ class DatabaseOperations:
|
||||
team_id: Optional team filter
|
||||
|
||||
Returns:
|
||||
List of SbaRosterLinkData
|
||||
List of SbaRosterLinkData with player_positions
|
||||
"""
|
||||
async with self._get_session() as session:
|
||||
query = select(RosterLink).where(
|
||||
@ -736,10 +782,70 @@ class DatabaseOperations:
|
||||
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 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:
|
||||
"""
|
||||
Remove a roster entry by ID.
|
||||
|
||||
@ -43,6 +43,11 @@ class RosterLink(Base):
|
||||
SBA League: Uses player_id to track which players are rostered
|
||||
|
||||
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"
|
||||
@ -60,6 +65,14 @@ class RosterLink(Base):
|
||||
player_id = Column(Integer, nullable=True) # SBA only
|
||||
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
|
||||
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 uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, computed_field, field_validator
|
||||
|
||||
|
||||
class BaseRosterLinkData(BaseModel, ABC):
|
||||
@ -62,9 +62,18 @@ class SbaRosterLinkData(BaseRosterLinkData):
|
||||
|
||||
Used for SBA league games where rosters are composed of players.
|
||||
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_positions: list[str] = []
|
||||
player_data: dict | None = None # Cached SbaPlayer fields: {name, image, headshot}
|
||||
|
||||
@field_validator("player_id")
|
||||
@classmethod
|
||||
@ -79,6 +88,34 @@ class SbaRosterLinkData(BaseRosterLinkData):
|
||||
def get_entity_type(self) -> str:
|
||||
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):
|
||||
"""Request model for creating a roster link"""
|
||||
@ -87,6 +124,7 @@ class RosterLinkCreate(BaseModel):
|
||||
team_id: int
|
||||
card_id: int | None = None
|
||||
player_id: int | None = None
|
||||
player_positions: list[str] = [] # Natural positions for substitution filtering
|
||||
|
||||
@field_validator("team_id")
|
||||
@classmethod
|
||||
@ -116,5 +154,8 @@ class RosterLinkCreate(BaseModel):
|
||||
if self.player_id is None:
|
||||
raise ValueError("player_id required for SBA roster")
|
||||
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"}
|
||||
)
|
||||
|
||||
@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
|
||||
async def submit_defensive_decision(sid, data):
|
||||
"""
|
||||
|
||||
@ -736,6 +736,141 @@ class TestDatabaseOperationsRoster:
|
||||
roster = await db_ops.get_sba_roster(game_id)
|
||||
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:
|
||||
"""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,
|
||||
RollData,
|
||||
Lineup,
|
||||
BenchPlayer,
|
||||
} from '~/types'
|
||||
|
||||
export const useGameStore = defineStore('game', () => {
|
||||
@ -25,6 +26,8 @@ export const useGameStore = defineStore('game', () => {
|
||||
const gameState = ref<GameState | null>(null)
|
||||
const homeLineup = ref<Lineup[]>([])
|
||||
const awayLineup = ref<Lineup[]>([])
|
||||
const homeBench = ref<BenchPlayer[]>([])
|
||||
const awayBench = ref<BenchPlayer[]>([])
|
||||
const playHistory = ref<PlayResult[]>([])
|
||||
const currentDecisionPrompt = ref<DecisionPrompt | 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)
|
||||
* O(1) operation - no deduplication needed
|
||||
@ -323,6 +339,8 @@ export const useGameStore = defineStore('game', () => {
|
||||
gameState.value = null
|
||||
homeLineup.value = []
|
||||
awayLineup.value = []
|
||||
homeBench.value = []
|
||||
awayBench.value = []
|
||||
playHistory.value = []
|
||||
currentDecisionPrompt.value = null
|
||||
pendingRoll.value = null
|
||||
@ -373,6 +391,8 @@ export const useGameStore = defineStore('game', () => {
|
||||
gameState: readonly(gameState),
|
||||
homeLineup: readonly(homeLineup),
|
||||
awayLineup: readonly(awayLineup),
|
||||
homeBench: readonly(homeBench),
|
||||
awayBench: readonly(awayBench),
|
||||
playHistory: readonly(playHistory),
|
||||
currentDecisionPrompt: readonly(currentDecisionPrompt),
|
||||
pendingRoll: readonly(pendingRoll),
|
||||
@ -421,6 +441,7 @@ export const useGameStore = defineStore('game', () => {
|
||||
updateGameState,
|
||||
setLineups,
|
||||
updateLineup,
|
||||
setBench,
|
||||
setPlayHistory,
|
||||
addPlayToHistory,
|
||||
setDecisionPrompt,
|
||||
|
||||
@ -37,6 +37,7 @@ export type {
|
||||
SbaPlayer,
|
||||
Lineup,
|
||||
TeamLineup,
|
||||
BenchPlayer,
|
||||
LineupDataResponse,
|
||||
SubstitutionType,
|
||||
SubstitutionRequest,
|
||||
|
||||
@ -83,6 +83,33 @@ export interface TeamLineup {
|
||||
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
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user