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:
Cal Corum 2026-01-17 22:15:12 -06:00
parent 64325d7163
commit e058bc4a6c
15 changed files with 1650 additions and 10 deletions

View File

@ -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')

View File

@ -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")

View File

@ -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(

View File

@ -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.

View File

@ -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")

View File

@ -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,
)

View File

@ -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):
"""

View File

@ -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.)"""

View 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>

View 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>

View 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>

View 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>

View File

@ -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,

View File

@ -37,6 +37,7 @@ export type {
SbaPlayer,
Lineup,
TeamLineup,
BenchPlayer,
LineupDataResponse,
SubstitutionType,
SubstitutionRequest,

View File

@ -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
*/