CLAUDE: Store team display info in DB and fix lineup auto-start
Backend: - Add game_metadata to create_game() and quick_create_game() endpoints - Fetch team display info (lname, sname, abbrev, color, thumbnail) from SBA API at game creation time and store in DB - Populate GameState with team display fields from game_metadata - Fix submit_team_lineup to cache lineup in state_manager after DB write so auto-start correctly detects both teams ready Frontend: - Read team colors/names/thumbnails from gameState instead of useState - Remove useState approach that failed across SSR navigation - Fix create.vue redirect from legacy /games/lineup/[id] to /games/[id] - Update game.vue header to show team names from gameState Docs: - Update CLAUDE.md to note dev mode has broken auth, always use prod Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ff3f1746d6
commit
d60b7a2d60
14
CLAUDE.md
14
CLAUDE.md
@ -92,13 +92,15 @@ strat-gameplay-webapp/
|
|||||||
|
|
||||||
The entire stack runs in Docker with a single command. No local Python or Node.js required.
|
The entire stack runs in Docker with a single command. No local Python or Node.js required.
|
||||||
|
|
||||||
```bash
|
> **⚠️ ALWAYS USE PROD MODE**: Discord OAuth does not work in dev mode due to cookie/CORS configuration. Since this system isn't live yet, always build and run with `prod` mode for testing.
|
||||||
# Development (hot-reload enabled)
|
|
||||||
./start.sh dev
|
|
||||||
|
|
||||||
# Production (optimized build)
|
```bash
|
||||||
|
# Production (optimized build) - USE THIS
|
||||||
./start.sh prod
|
./start.sh prod
|
||||||
|
|
||||||
|
# Development - DO NOT USE (auth broken)
|
||||||
|
# ./start.sh dev
|
||||||
|
|
||||||
# Stop all services
|
# Stop all services
|
||||||
./start.sh stop
|
./start.sh stop
|
||||||
|
|
||||||
@ -116,8 +118,8 @@ The entire stack runs in Docker with a single command. No local Python or Node.j
|
|||||||
|
|
||||||
| Mode | Backend | Frontend | Use Case |
|
| Mode | Backend | Frontend | Use Case |
|
||||||
|------|---------|----------|----------|
|
|------|---------|----------|----------|
|
||||||
| `dev` | Hot-reload (uvicorn --reload) | Hot-reload (nuxt dev) | Active development |
|
| `prod` | Production build | SSR optimized build | **Always use this** - auth works correctly |
|
||||||
| `prod` | Production build | SSR optimized build | Demo/deployment |
|
| `dev` | Hot-reload (uvicorn --reload) | Hot-reload (nuxt dev) | ❌ Auth broken - do not use |
|
||||||
|
|
||||||
### Service URLs
|
### Service URLs
|
||||||
|
|
||||||
|
|||||||
@ -320,12 +320,38 @@ async def create_game(request: CreateGameRequest):
|
|||||||
status_code=400, detail="Home and away teams must be different"
|
status_code=400, detail="Home and away teams must be different"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fetch team display info from SBA API and build game_metadata
|
||||||
|
# This persists team data so it's always available regardless of season
|
||||||
|
teams_data = await sba_api_client.get_teams_by_ids(
|
||||||
|
[request.home_team_id, request.away_team_id], season=request.season
|
||||||
|
)
|
||||||
|
home_team_data = teams_data.get(request.home_team_id, {})
|
||||||
|
away_team_data = teams_data.get(request.away_team_id, {})
|
||||||
|
|
||||||
|
game_metadata = {
|
||||||
|
"home_team": {
|
||||||
|
"lname": home_team_data.get("lname"),
|
||||||
|
"sname": home_team_data.get("sname"),
|
||||||
|
"abbrev": home_team_data.get("abbrev"),
|
||||||
|
"color": home_team_data.get("color"),
|
||||||
|
"thumbnail": home_team_data.get("thumbnail"),
|
||||||
|
},
|
||||||
|
"away_team": {
|
||||||
|
"lname": away_team_data.get("lname"),
|
||||||
|
"sname": away_team_data.get("sname"),
|
||||||
|
"abbrev": away_team_data.get("abbrev"),
|
||||||
|
"color": away_team_data.get("color"),
|
||||||
|
"thumbnail": away_team_data.get("thumbnail"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# Create game in state manager (in-memory)
|
# Create game in state manager (in-memory)
|
||||||
state = await state_manager.create_game(
|
state = await state_manager.create_game(
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
league_id=request.league_id,
|
league_id=request.league_id,
|
||||||
home_team_id=request.home_team_id,
|
home_team_id=request.home_team_id,
|
||||||
away_team_id=request.away_team_id,
|
away_team_id=request.away_team_id,
|
||||||
|
game_metadata=game_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to database
|
# Save to database
|
||||||
@ -337,6 +363,7 @@ async def create_game(request: CreateGameRequest):
|
|||||||
away_team_id=request.away_team_id,
|
away_team_id=request.away_team_id,
|
||||||
game_mode="friendly" if not request.is_ai_opponent else "ai",
|
game_mode="friendly" if not request.is_ai_opponent else "ai",
|
||||||
visibility="public",
|
visibility="public",
|
||||||
|
game_metadata=game_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -407,6 +434,31 @@ async def quick_create_game(
|
|||||||
f"(creator: {creator_discord_id}, custom_teams: {use_custom_teams})"
|
f"(creator: {creator_discord_id}, custom_teams: {use_custom_teams})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fetch team display info from SBA API and build game_metadata
|
||||||
|
# This persists team data so it's always available regardless of season
|
||||||
|
teams_data = await sba_api_client.get_teams_by_ids(
|
||||||
|
[home_team_id, away_team_id], season=13
|
||||||
|
)
|
||||||
|
home_team_data = teams_data.get(home_team_id, {})
|
||||||
|
away_team_data = teams_data.get(away_team_id, {})
|
||||||
|
|
||||||
|
game_metadata = {
|
||||||
|
"home_team": {
|
||||||
|
"lname": home_team_data.get("lname"),
|
||||||
|
"sname": home_team_data.get("sname"),
|
||||||
|
"abbrev": home_team_data.get("abbrev"),
|
||||||
|
"color": home_team_data.get("color"),
|
||||||
|
"thumbnail": home_team_data.get("thumbnail"),
|
||||||
|
},
|
||||||
|
"away_team": {
|
||||||
|
"lname": away_team_data.get("lname"),
|
||||||
|
"sname": away_team_data.get("sname"),
|
||||||
|
"abbrev": away_team_data.get("abbrev"),
|
||||||
|
"color": away_team_data.get("color"),
|
||||||
|
"thumbnail": away_team_data.get("thumbnail"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# Create game in state manager
|
# Create game in state manager
|
||||||
state = await state_manager.create_game(
|
state = await state_manager.create_game(
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
@ -414,6 +466,7 @@ async def quick_create_game(
|
|||||||
home_team_id=home_team_id,
|
home_team_id=home_team_id,
|
||||||
away_team_id=away_team_id,
|
away_team_id=away_team_id,
|
||||||
creator_discord_id=creator_discord_id,
|
creator_discord_id=creator_discord_id,
|
||||||
|
game_metadata=game_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to database
|
# Save to database
|
||||||
@ -426,6 +479,7 @@ async def quick_create_game(
|
|||||||
game_mode="friendly",
|
game_mode="friendly",
|
||||||
visibility="public",
|
visibility="public",
|
||||||
schedule_game_id=schedule_game_id,
|
schedule_game_id=schedule_game_id,
|
||||||
|
game_metadata=game_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
if use_custom_teams:
|
if use_custom_teams:
|
||||||
@ -1023,6 +1077,16 @@ async def submit_team_lineup(game_id: str, request: SubmitTeamLineupRequest):
|
|||||||
|
|
||||||
logger.info(f"Added {player_count} players to team {request.team_id} lineup")
|
logger.info(f"Added {player_count} players to team {request.team_id} lineup")
|
||||||
|
|
||||||
|
# Load lineup from DB and cache in state_manager for subsequent checks
|
||||||
|
team_lineup = await lineup_service.load_team_lineup_with_player_data(
|
||||||
|
game_id=game_uuid,
|
||||||
|
team_id=request.team_id,
|
||||||
|
league_id=state.league_id,
|
||||||
|
)
|
||||||
|
if team_lineup:
|
||||||
|
state_manager.set_lineup(game_uuid, request.team_id, team_lineup)
|
||||||
|
logger.info(f"Cached lineup for team {request.team_id} in state_manager")
|
||||||
|
|
||||||
# Check if both teams now have lineups
|
# Check if both teams now have lineups
|
||||||
home_lineup = state_manager.get_lineup(game_uuid, state.home_team_id)
|
home_lineup = state_manager.get_lineup(game_uuid, state.home_team_id)
|
||||||
away_lineup = state_manager.get_lineup(game_uuid, state.away_team_id)
|
away_lineup = state_manager.get_lineup(game_uuid, state.away_team_id)
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class TeamResponse(BaseModel):
|
|||||||
sname: str
|
sname: str
|
||||||
lname: str
|
lname: str
|
||||||
color: str | None = None
|
color: str | None = None
|
||||||
|
thumbnail: str | None = None
|
||||||
manager_legacy: str | None = None
|
manager_legacy: str | None = None
|
||||||
gmid: str | None = None
|
gmid: str | None = None
|
||||||
gmid2: str | None = None
|
gmid2: str | None = None
|
||||||
@ -46,6 +47,7 @@ async def get_teams(season: int = Query(..., description="Season number (e.g., 3
|
|||||||
sname=team["sname"],
|
sname=team["sname"],
|
||||||
lname=team["lname"],
|
lname=team["lname"],
|
||||||
color=team.get("color"),
|
color=team.get("color"),
|
||||||
|
thumbnail=team.get("thumbnail"),
|
||||||
manager_legacy=team.get("manager_legacy"),
|
manager_legacy=team.get("manager_legacy"),
|
||||||
gmid=team.get("gmid"),
|
gmid=team.get("gmid"),
|
||||||
gmid2=team.get("gmid2"),
|
gmid2=team.get("gmid2"),
|
||||||
|
|||||||
@ -94,6 +94,7 @@ class StateManager:
|
|||||||
away_team_is_ai: bool = False,
|
away_team_is_ai: bool = False,
|
||||||
auto_mode: bool = False,
|
auto_mode: bool = False,
|
||||||
creator_discord_id: str | None = None,
|
creator_discord_id: str | None = None,
|
||||||
|
game_metadata: dict | None = None,
|
||||||
) -> GameState:
|
) -> GameState:
|
||||||
"""
|
"""
|
||||||
Create a new game state in memory.
|
Create a new game state in memory.
|
||||||
@ -106,6 +107,7 @@ class StateManager:
|
|||||||
home_team_is_ai: Whether home team is AI-controlled
|
home_team_is_ai: Whether home team is AI-controlled
|
||||||
away_team_is_ai: Whether away team is AI-controlled
|
away_team_is_ai: Whether away team is AI-controlled
|
||||||
auto_mode: True = auto-generate outcomes (PD only), False = manual submissions
|
auto_mode: True = auto-generate outcomes (PD only), False = manual submissions
|
||||||
|
game_metadata: Optional dict with team display info (lname, abbrev, color, thumbnail)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Newly created GameState
|
Newly created GameState
|
||||||
@ -127,6 +129,10 @@ class StateManager:
|
|||||||
lineup_id=0, card_id=0, position="DH", batting_order=None
|
lineup_id=0, card_id=0, position="DH", batting_order=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Extract team display info from metadata
|
||||||
|
home_meta = game_metadata.get("home_team", {}) if game_metadata else {}
|
||||||
|
away_meta = game_metadata.get("away_team", {}) if game_metadata else {}
|
||||||
|
|
||||||
state = GameState(
|
state = GameState(
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
league_id=league_id,
|
league_id=league_id,
|
||||||
@ -137,6 +143,15 @@ class StateManager:
|
|||||||
auto_mode=auto_mode,
|
auto_mode=auto_mode,
|
||||||
creator_discord_id=creator_discord_id,
|
creator_discord_id=creator_discord_id,
|
||||||
current_batter=placeholder_batter, # Will be replaced by _prepare_next_play() when game starts
|
current_batter=placeholder_batter, # Will be replaced by _prepare_next_play() when game starts
|
||||||
|
# Team display info from metadata
|
||||||
|
home_team_name=home_meta.get("lname"),
|
||||||
|
home_team_abbrev=home_meta.get("abbrev"),
|
||||||
|
home_team_color=home_meta.get("color"),
|
||||||
|
home_team_thumbnail=home_meta.get("thumbnail"),
|
||||||
|
away_team_name=away_meta.get("lname"),
|
||||||
|
away_team_abbrev=away_meta.get("abbrev"),
|
||||||
|
away_team_color=away_meta.get("color"),
|
||||||
|
away_team_thumbnail=away_meta.get("thumbnail"),
|
||||||
)
|
)
|
||||||
|
|
||||||
self._states[game_id] = state
|
self._states[game_id] = state
|
||||||
|
|||||||
@ -103,6 +103,7 @@ class DatabaseOperations:
|
|||||||
away_team_is_ai: bool = False,
|
away_team_is_ai: bool = False,
|
||||||
ai_difficulty: str | None = None,
|
ai_difficulty: str | None = None,
|
||||||
schedule_game_id: int | None = None,
|
schedule_game_id: int | None = None,
|
||||||
|
game_metadata: dict | None = None,
|
||||||
) -> Game:
|
) -> Game:
|
||||||
"""
|
"""
|
||||||
Create new game in database.
|
Create new game in database.
|
||||||
@ -118,6 +119,7 @@ class DatabaseOperations:
|
|||||||
away_team_is_ai: Whether away team is AI
|
away_team_is_ai: Whether away team is AI
|
||||||
ai_difficulty: AI difficulty if applicable
|
ai_difficulty: AI difficulty if applicable
|
||||||
schedule_game_id: External schedule game ID for linking (SBA, PD, etc.)
|
schedule_game_id: External schedule game ID for linking (SBA, PD, etc.)
|
||||||
|
game_metadata: Optional dict with team display info (lname, abbrev, color, thumbnail)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created Game model
|
Created Game model
|
||||||
@ -137,6 +139,7 @@ class DatabaseOperations:
|
|||||||
away_team_is_ai=away_team_is_ai,
|
away_team_is_ai=away_team_is_ai,
|
||||||
ai_difficulty=ai_difficulty,
|
ai_difficulty=ai_difficulty,
|
||||||
schedule_game_id=schedule_game_id,
|
schedule_game_id=schedule_game_id,
|
||||||
|
game_metadata=game_metadata or {},
|
||||||
status="pending",
|
status="pending",
|
||||||
)
|
)
|
||||||
session.add(game)
|
session.add(game)
|
||||||
|
|||||||
@ -396,6 +396,16 @@ class GameState(BaseModel):
|
|||||||
home_team_is_ai: bool = False
|
home_team_is_ai: bool = False
|
||||||
away_team_is_ai: bool = False
|
away_team_is_ai: bool = False
|
||||||
|
|
||||||
|
# Team display info (for UI - fetched from league API when game created)
|
||||||
|
home_team_name: str | None = None # e.g., "Chicago Cyclones"
|
||||||
|
home_team_abbrev: str | None = None # e.g., "CHC"
|
||||||
|
home_team_color: str | None = None # e.g., "ff5349" (no # prefix)
|
||||||
|
home_team_thumbnail: str | None = None # Team logo URL
|
||||||
|
away_team_name: str | None = None
|
||||||
|
away_team_abbrev: str | None = None
|
||||||
|
away_team_color: str | None = None
|
||||||
|
away_team_thumbnail: str | None = None
|
||||||
|
|
||||||
# Creator (for demo/testing - creator can control home team)
|
# Creator (for demo/testing - creator can control home team)
|
||||||
creator_discord_id: str | None = None
|
creator_discord_id: str | None = None
|
||||||
|
|
||||||
|
|||||||
@ -55,9 +55,13 @@ frontend-sba/
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
> **⚠️ ALWAYS USE PROD MODE**: Run the full stack via Docker with `./start.sh prod` from the project root. Dev mode (`./start.sh dev`) has broken Discord OAuth due to cookie/CORS issues. The system isn't live yet, so always use prod for testing.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install # First time
|
# From project root - use this for testing
|
||||||
npm run dev # Dev server at http://localhost:3000
|
./start.sh prod
|
||||||
|
|
||||||
|
# Local commands (for type checking only, not running)
|
||||||
npm run type-check # Check types
|
npm run type-check # Check types
|
||||||
npm run lint # Lint code
|
npm run lint # Lint code
|
||||||
```
|
```
|
||||||
|
|||||||
@ -11,13 +11,14 @@
|
|||||||
<!-- Pitcher Image/Badge -->
|
<!-- Pitcher Image/Badge -->
|
||||||
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden">
|
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
v-if="pitcherPlayer?.headshot"
|
v-if="getPlayerPreviewImage(pitcherPlayer)"
|
||||||
:src="pitcherPlayer.headshot"
|
:src="getPlayerPreviewImage(pitcherPlayer)!"
|
||||||
:alt="pitcherName"
|
:alt="pitcherName"
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
|
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||||
>
|
>
|
||||||
<div v-else class="w-full h-full bg-blue-500 flex items-center justify-center text-white font-bold text-lg">
|
<div v-else class="w-full h-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-lg">
|
||||||
P
|
{{ getPlayerFallbackInitial(pitcherPlayer) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -52,13 +53,14 @@
|
|||||||
<!-- Batter Image/Badge -->
|
<!-- Batter Image/Badge -->
|
||||||
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden">
|
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
v-if="batterPlayer?.headshot"
|
v-if="getPlayerPreviewImage(batterPlayer)"
|
||||||
:src="batterPlayer.headshot"
|
:src="getPlayerPreviewImage(batterPlayer)!"
|
||||||
:alt="batterName"
|
:alt="batterName"
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
|
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||||
>
|
>
|
||||||
<div v-else class="w-full h-full bg-red-500 flex items-center justify-center text-white font-bold text-lg">
|
<div v-else class="w-full h-full bg-gradient-to-br from-red-500 to-red-600 flex items-center justify-center text-white font-bold text-lg">
|
||||||
B
|
{{ getPlayerFallbackInitial(batterPlayer) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -90,13 +92,14 @@
|
|||||||
<!-- Pitcher Image/Badge -->
|
<!-- Pitcher Image/Badge -->
|
||||||
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden">
|
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
v-if="pitcherPlayer?.headshot"
|
v-if="getPlayerPreviewImage(pitcherPlayer)"
|
||||||
:src="pitcherPlayer.headshot"
|
:src="getPlayerPreviewImage(pitcherPlayer)!"
|
||||||
:alt="pitcherName"
|
:alt="pitcherName"
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
|
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||||
>
|
>
|
||||||
<div v-else class="w-full h-full bg-blue-500 flex items-center justify-center text-white font-bold text-2xl">
|
<div v-else class="w-full h-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-2xl">
|
||||||
P
|
{{ getPlayerFallbackInitial(pitcherPlayer) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -124,13 +127,14 @@
|
|||||||
<!-- Batter Image/Badge -->
|
<!-- Batter Image/Badge -->
|
||||||
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden">
|
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
v-if="batterPlayer?.headshot"
|
v-if="getPlayerPreviewImage(batterPlayer)"
|
||||||
:src="batterPlayer.headshot"
|
:src="getPlayerPreviewImage(batterPlayer)!"
|
||||||
:alt="batterName"
|
:alt="batterName"
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
|
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||||
>
|
>
|
||||||
<div v-else class="w-full h-full bg-red-500 flex items-center justify-center text-white font-bold text-2xl">
|
<div v-else class="w-full h-full bg-gradient-to-br from-red-500 to-red-600 flex items-center justify-center text-white font-bold text-2xl">
|
||||||
B
|
{{ getPlayerFallbackInitial(batterPlayer) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -221,6 +225,25 @@ const pitcherName = computed(() => {
|
|||||||
if (!props.currentPitcher) return 'Unknown Pitcher'
|
if (!props.currentPitcher) return 'Unknown Pitcher'
|
||||||
return `Player #${props.currentPitcher.card_id || props.currentPitcher.lineup_id}`
|
return `Player #${props.currentPitcher.card_id || props.currentPitcher.lineup_id}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get player preview image with fallback priority: headshot > vanity_card > null
|
||||||
|
function getPlayerPreviewImage(player: { headshot?: string | null; vanity_card?: string | null } | null): string | null {
|
||||||
|
if (!player) return null
|
||||||
|
return player.headshot || player.vanity_card || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get player avatar fallback - use first + last initials (e.g., "Alex Verdugo" -> "AV")
|
||||||
|
// Ignores common suffixes like Jr, Sr, II, III, IV
|
||||||
|
function getPlayerFallbackInitial(player: { name: string } | null): string {
|
||||||
|
if (!player) return '?'
|
||||||
|
const suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'v']
|
||||||
|
const parts = player.name.trim().split(/\s+/).filter(
|
||||||
|
part => !suffixes.includes(part.toLowerCase())
|
||||||
|
)
|
||||||
|
if (parts.length === 0) return '?'
|
||||||
|
if (parts.length === 1) return parts[0].charAt(0).toUpperCase()
|
||||||
|
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -14,10 +14,18 @@
|
|||||||
<!-- Score Display with Game Situation -->
|
<!-- Score Display with Game Situation -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<!-- Away Team -->
|
<!-- Away Team -->
|
||||||
<div class="flex-1 text-center">
|
<div class="flex-1 text-center relative">
|
||||||
|
<img
|
||||||
|
v-if="awayTeamThumbnail"
|
||||||
|
:src="awayTeamThumbnail"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 w-full h-full object-contain opacity-15 pointer-events-none"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
<div class="text-xs font-medium text-blue-100 mb-1">AWAY</div>
|
<div class="text-xs font-medium text-blue-100 mb-1">AWAY</div>
|
||||||
<div class="text-4xl font-bold tabular-nums">{{ awayScore }}</div>
|
<div class="text-4xl font-bold tabular-nums">{{ awayScore }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Center: Inning + Runners/Outs -->
|
<!-- Center: Inning + Runners/Outs -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@ -77,22 +85,38 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Home Team -->
|
<!-- Home Team -->
|
||||||
<div class="flex-1 text-center">
|
<div class="flex-1 text-center relative">
|
||||||
|
<img
|
||||||
|
v-if="homeTeamThumbnail"
|
||||||
|
:src="homeTeamThumbnail"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 w-full h-full object-contain opacity-15 pointer-events-none"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
<div class="text-xs font-medium text-blue-100 mb-1">HOME</div>
|
<div class="text-xs font-medium text-blue-100 mb-1">HOME</div>
|
||||||
<div class="text-4xl font-bold tabular-nums">{{ homeScore }}</div>
|
<div class="text-4xl font-bold tabular-nums">{{ homeScore }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Desktop Layout (lg and up) -->
|
<!-- Desktop Layout (lg and up) -->
|
||||||
<div class="hidden lg:flex items-center justify-between">
|
<div class="hidden lg:flex items-center justify-between">
|
||||||
<!-- Left: Away Team Score -->
|
<!-- Left: Away Team Score -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="text-center min-w-[100px]">
|
<div class="text-center min-w-[100px] relative">
|
||||||
|
<img
|
||||||
|
v-if="awayTeamThumbnail"
|
||||||
|
:src="awayTeamThumbnail"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 w-full h-full object-contain opacity-15 pointer-events-none"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
<div class="text-sm font-medium text-blue-100">AWAY</div>
|
<div class="text-sm font-medium text-blue-100">AWAY</div>
|
||||||
<div class="text-5xl font-bold tabular-nums">{{ awayScore }}</div>
|
<div class="text-5xl font-bold tabular-nums">{{ awayScore }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Center: Game Situation -->
|
<!-- Center: Game Situation -->
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
@ -153,7 +177,14 @@
|
|||||||
|
|
||||||
<!-- Right: Home Team Score -->
|
<!-- Right: Home Team Score -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="text-center min-w-[100px]">
|
<div class="text-center min-w-[100px] relative">
|
||||||
|
<img
|
||||||
|
v-if="homeTeamThumbnail"
|
||||||
|
:src="homeTeamThumbnail"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 w-full h-full object-contain opacity-15 pointer-events-none"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
<div class="text-sm font-medium text-blue-100">HOME</div>
|
<div class="text-sm font-medium text-blue-100">HOME</div>
|
||||||
<div class="text-5xl font-bold tabular-nums">{{ homeScore }}</div>
|
<div class="text-5xl font-bold tabular-nums">{{ homeScore }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -161,6 +192,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -180,6 +212,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
awayTeamColor?: string
|
awayTeamColor?: string
|
||||||
homeTeamColor?: string
|
homeTeamColor?: string
|
||||||
|
awayTeamThumbnail?: string | null
|
||||||
|
homeTeamThumbnail?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@ -190,7 +224,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
outs: 0,
|
outs: 0,
|
||||||
runners: () => ({ first: false, second: false, third: false }),
|
runners: () => ({ first: false, second: false, third: false }),
|
||||||
awayTeamColor: undefined,
|
awayTeamColor: undefined,
|
||||||
homeTeamColor: undefined
|
homeTeamColor: undefined,
|
||||||
|
awayTeamThumbnail: undefined,
|
||||||
|
homeTeamThumbnail: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// Generate gradient style from team colors
|
// Generate gradient style from team colors
|
||||||
|
|||||||
@ -24,8 +24,10 @@
|
|||||||
<span>Back to Games</span>
|
<span>Back to Games</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Logo -->
|
<!-- Matchup -->
|
||||||
<div class="text-lg font-bold">SBA</div>
|
<div class="text-sm font-bold text-center truncate max-w-[200px] sm:max-w-none sm:text-lg">
|
||||||
|
{{ matchupText }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Connection Status -->
|
<!-- Connection Status -->
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
@ -67,12 +69,23 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAuthStore } from '~/store/auth'
|
import { useAuthStore } from '~/store/auth'
|
||||||
|
import { useGameStore } from '~/store/game'
|
||||||
import { useWebSocket } from '~/composables/useWebSocket'
|
import { useWebSocket } from '~/composables/useWebSocket'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
|
||||||
// WebSocket connection status - use composable directly as source of truth
|
// WebSocket connection status - use composable directly as source of truth
|
||||||
const { isConnected } = useWebSocket()
|
const { isConnected } = useWebSocket()
|
||||||
|
|
||||||
|
// Team names for header (from gameState, stored in DB at game creation)
|
||||||
|
const matchupText = computed(() => {
|
||||||
|
const gs = gameStore.gameState
|
||||||
|
if (gs?.away_team_name && gs?.home_team_name) {
|
||||||
|
return `${gs.away_team_name} @ ${gs.home_team_name}`
|
||||||
|
}
|
||||||
|
return 'SBA'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
:runners="runnersState"
|
:runners="runnersState"
|
||||||
:away-team-color="awayTeamColor"
|
:away-team-color="awayTeamColor"
|
||||||
:home-team-color="homeTeamColor"
|
:home-team-color="homeTeamColor"
|
||||||
|
:away-team-thumbnail="awayTeamThumbnail"
|
||||||
|
:home-team-thumbnail="homeTeamThumbnail"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Tab Navigation (sticky below header) -->
|
<!-- Tab Navigation (sticky below header) -->
|
||||||
@ -67,7 +69,6 @@
|
|||||||
import { useGameStore } from '~/store/game'
|
import { useGameStore } from '~/store/game'
|
||||||
import { useAuthStore } from '~/store/auth'
|
import { useAuthStore } from '~/store/auth'
|
||||||
import { useUiStore } from '~/store/ui'
|
import { useUiStore } from '~/store/ui'
|
||||||
import type { SbaTeam } from '~/types/api'
|
|
||||||
import ScoreBoard from '~/components/Game/ScoreBoard.vue'
|
import ScoreBoard from '~/components/Game/ScoreBoard.vue'
|
||||||
import GamePlay from '~/components/Game/GamePlay.vue'
|
import GamePlay from '~/components/Game/GamePlay.vue'
|
||||||
import LineupBuilder from '~/components/Game/LineupBuilder.vue'
|
import LineupBuilder from '~/components/Game/LineupBuilder.vue'
|
||||||
@ -78,17 +79,13 @@ definePageMeta({
|
|||||||
middleware: ['auth'],
|
middleware: ['auth'],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Config
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
|
|
||||||
// Stores
|
// Stores
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
// Team data for colors
|
// Note: Team display info now comes directly from gameState (stored in DB at game creation)
|
||||||
const teamsMap = ref<Map<number, SbaTeam>>(new Map())
|
|
||||||
|
|
||||||
// Game ID from route
|
// Game ID from route
|
||||||
const gameId = computed(() => route.params.id as string)
|
const gameId = computed(() => route.params.id as string)
|
||||||
@ -121,62 +118,23 @@ const runnersState = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Team colors for ScoreBoard gradient
|
// Team colors for ScoreBoard gradient (from gameState, stored in DB at creation)
|
||||||
const awayTeamColor = computed(() => {
|
const awayTeamColor = computed(() => {
|
||||||
if (!gameState.value) return undefined
|
const color = gameState.value?.away_team_color
|
||||||
return teamsMap.value.get(gameState.value.away_team_id)?.color
|
// Add # prefix if color exists but doesn't have it
|
||||||
|
return color ? (color.startsWith('#') ? color : `#${color}`) : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const homeTeamColor = computed(() => {
|
const homeTeamColor = computed(() => {
|
||||||
if (!gameState.value) return undefined
|
const color = gameState.value?.home_team_color
|
||||||
return teamsMap.value.get(gameState.value.home_team_id)?.color
|
// Add # prefix if color exists but doesn't have it
|
||||||
|
return color ? (color.startsWith('#') ? color : `#${color}`) : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get season from schedule state (already fetched on /games page)
|
// Team thumbnails for ScoreBoard
|
||||||
const selectedSeason = useState<number>('schedule-season')
|
const awayTeamThumbnail = computed(() => gameState.value?.away_team_thumbnail)
|
||||||
|
|
||||||
// Fetch team data when game state becomes available
|
const homeTeamThumbnail = computed(() => gameState.value?.home_team_thumbnail)
|
||||||
watch(gameState, async (state) => {
|
|
||||||
if (!state) return
|
|
||||||
|
|
||||||
const teamIds = [state.home_team_id, state.away_team_id]
|
|
||||||
|
|
||||||
// Only fetch teams we don't have yet
|
|
||||||
const missingIds = teamIds.filter(id => !teamsMap.value.has(id))
|
|
||||||
if (missingIds.length === 0) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get season - use cached value or fetch current
|
|
||||||
let season = selectedSeason.value
|
|
||||||
if (!season) {
|
|
||||||
console.log('[Game Page] No cached season, fetching current...')
|
|
||||||
const currentInfo = await $fetch<{ season: number; week: number }>(`${config.public.apiUrl}/api/schedule/current`, {
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
season = currentInfo.season
|
|
||||||
selectedSeason.value = season // Cache it
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Game Page] Fetching teams for season', season)
|
|
||||||
|
|
||||||
// Fetch all teams for the season and filter to the ones we need
|
|
||||||
const teams = await $fetch<SbaTeam[]>(`${config.public.apiUrl}/api/teams/`, {
|
|
||||||
credentials: 'include',
|
|
||||||
query: { season }
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log('[Game Page] Got teams, looking for IDs:', teamIds)
|
|
||||||
|
|
||||||
for (const team of teams) {
|
|
||||||
if (teamIds.includes(team.id)) {
|
|
||||||
console.log('[Game Page] Found team:', team.id, team.sname, 'color:', team.color)
|
|
||||||
teamsMap.value.set(team.id, team)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[Game Page] Failed to fetch team data for colors:', error)
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// Check if user is a manager of either team in this game
|
// Check if user is a manager of either team in this game
|
||||||
const isUserManager = computed(() => {
|
const isUserManager = computed(() => {
|
||||||
|
|||||||
@ -174,8 +174,8 @@ const handleCreateGame = async () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Redirect to lineup builder
|
// Redirect to game page (lineup is a tab there)
|
||||||
router.push(`/games/lineup/${response.game_id}`)
|
router.push(`/games/${response.game_id}`)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.data?.detail || err.message || 'Failed to create game'
|
error.value = err.data?.detail || err.message || 'Failed to create game'
|
||||||
console.error('Create game error:', err)
|
console.error('Create game error:', err)
|
||||||
|
|||||||
@ -144,7 +144,7 @@
|
|||||||
Final
|
Final
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<!-- Active webapp game: show "In Progress" link -->
|
<!-- Active webapp game: show "In Progress" link (team data stored in DB) -->
|
||||||
<template v-else-if="activeScheduleGameMap.get(game.id)">
|
<template v-else-if="activeScheduleGameMap.get(game.id)">
|
||||||
<span class="flex-1"></span>
|
<span class="flex-1"></span>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@ -158,7 +158,7 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="flex-1"></span>
|
<span class="flex-1"></span>
|
||||||
<button
|
<button
|
||||||
@click="handlePlayScheduledGame(game.home_team.id, game.away_team.id, game.id)"
|
@click="handlePlayScheduledGame(game)"
|
||||||
:disabled="isCreatingQuickGame"
|
:disabled="isCreatingQuickGame"
|
||||||
class="px-2 py-1 text-xs bg-primary hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium rounded transition disabled:cursor-not-allowed"
|
class="px-2 py-1 text-xs bg-primary hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium rounded transition disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
@ -334,6 +334,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAuthStore } from '~/store/auth'
|
import { useAuthStore } from '~/store/auth'
|
||||||
|
import type { SbaScheduledGame } from '~/types/schedule'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['auth'], // Require authentication
|
middleware: ['auth'], // Require authentication
|
||||||
@ -438,12 +439,14 @@ async function handleQuickCreate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a game from a scheduled matchup
|
// Create a game from a scheduled matchup
|
||||||
async function handlePlayScheduledGame(homeTeamId: number, awayTeamId: number, scheduleGameId: number) {
|
// Note: Team display info is now stored in DB by backend - no need to pass via useState
|
||||||
|
async function handlePlayScheduledGame(scheduledGame: SbaScheduledGame) {
|
||||||
try {
|
try {
|
||||||
isCreatingQuickGame.value = true
|
isCreatingQuickGame.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
console.log(`[Games Page] Creating game: ${awayTeamId} @ ${homeTeamId} (schedule_game_id: ${scheduleGameId})`)
|
const { home_team, away_team, id: scheduleGameId } = scheduledGame
|
||||||
|
console.log(`[Games Page] Creating game: ${away_team.sname} @ ${home_team.sname} (schedule_game_id: ${scheduleGameId})`)
|
||||||
|
|
||||||
const response = await $fetch<{ game_id: string; message: string; status: string }>(
|
const response = await $fetch<{ game_id: string; message: string; status: string }>(
|
||||||
`${config.public.apiUrl}/api/games/quick-create`,
|
`${config.public.apiUrl}/api/games/quick-create`,
|
||||||
@ -451,8 +454,8 @@ async function handlePlayScheduledGame(homeTeamId: number, awayTeamId: number, s
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: {
|
body: {
|
||||||
home_team_id: homeTeamId,
|
home_team_id: home_team.id,
|
||||||
away_team_id: awayTeamId,
|
away_team_id: away_team.id,
|
||||||
schedule_game_id: scheduleGameId,
|
schedule_game_id: scheduleGameId,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -460,7 +463,7 @@ async function handlePlayScheduledGame(homeTeamId: number, awayTeamId: number, s
|
|||||||
|
|
||||||
console.log('[Games Page] Created game from schedule:', response)
|
console.log('[Games Page] Created game from schedule:', response)
|
||||||
|
|
||||||
// Redirect to game page
|
// Redirect to game page (team display info is stored in DB by backend)
|
||||||
router.push(`/games/${response.game_id}`)
|
router.push(`/games/${response.game_id}`)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Games Page] Failed to create game from schedule:', err)
|
console.error('[Games Page] Failed to create game from schedule:', err)
|
||||||
|
|||||||
@ -95,6 +95,7 @@ export interface SbaTeam {
|
|||||||
sname: string // Short name (e.g., "Geese")
|
sname: string // Short name (e.g., "Geese")
|
||||||
lname: string // Long name (e.g., "Everett Geese")
|
lname: string // Long name (e.g., "Everett Geese")
|
||||||
color: string // Hex color code
|
color: string // Hex color code
|
||||||
|
thumbnail: string | null // Team logo URL
|
||||||
manager_legacy: string
|
manager_legacy: string
|
||||||
gmid: string | null
|
gmid: string | null
|
||||||
gmid2: string | null
|
gmid2: string | null
|
||||||
|
|||||||
@ -72,6 +72,16 @@ export interface GameState {
|
|||||||
home_team_is_ai: boolean
|
home_team_is_ai: boolean
|
||||||
away_team_is_ai: boolean
|
away_team_is_ai: boolean
|
||||||
|
|
||||||
|
// Team display info (from game_metadata, stored at creation time)
|
||||||
|
home_team_name?: string | null // Full name: "Chicago Cyclones"
|
||||||
|
home_team_abbrev?: string | null // Abbreviation: "CHC"
|
||||||
|
home_team_color?: string | null // Hex color without #: "ff5349"
|
||||||
|
home_team_thumbnail?: string | null // Team logo URL
|
||||||
|
away_team_name?: string | null
|
||||||
|
away_team_abbrev?: string | null
|
||||||
|
away_team_color?: string | null
|
||||||
|
away_team_thumbnail?: string | null
|
||||||
|
|
||||||
// Creator (for demo/testing - creator can control home team)
|
// Creator (for demo/testing - creator can control home team)
|
||||||
creator_discord_id: string | null
|
creator_discord_id: string | null
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user