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:
Cal Corum 2026-01-17 08:43:26 -06:00
parent ff3f1746d6
commit d60b7a2d60
15 changed files with 247 additions and 103 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,9 +14,17 @@
<!-- 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">
<div class="text-xs font-medium text-blue-100 mb-1">AWAY</div> <img
<div class="text-4xl font-bold tabular-nums">{{ awayScore }}</div> 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-4xl font-bold tabular-nums">{{ awayScore }}</div>
</div>
</div> </div>
<!-- Center: Inning + Runners/Outs --> <!-- Center: Inning + Runners/Outs -->
@ -77,9 +85,17 @@
</div> </div>
<!-- Home Team --> <!-- Home Team -->
<div class="flex-1 text-center"> <div class="flex-1 text-center relative">
<div class="text-xs font-medium text-blue-100 mb-1">HOME</div> <img
<div class="text-4xl font-bold tabular-nums">{{ homeScore }}</div> 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-4xl font-bold tabular-nums">{{ homeScore }}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -88,9 +104,17 @@
<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">
<div class="text-sm font-medium text-blue-100">AWAY</div> <img
<div class="text-5xl font-bold tabular-nums">{{ awayScore }}</div> 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-5xl font-bold tabular-nums">{{ awayScore }}</div>
</div>
</div> </div>
</div> </div>
@ -153,9 +177,17 @@
<!-- 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">
<div class="text-sm font-medium text-blue-100">HOME</div> <img
<div class="text-5xl font-bold tabular-nums">{{ homeScore }}</div> 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-5xl font-bold tabular-nums">{{ homeScore }}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -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

View File

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

View File

@ -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(() => {

View File

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

View File

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

View File

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

View File

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