diff --git a/backend/Dockerfile b/backend/Dockerfile index f8aba3c..646c146 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -68,4 +68,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8000/api/health || exit 1 # Run with production server -CMD ["uv", "run", "python", "-m", "uvicorn", "app.main:socket_app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] \ No newline at end of file +# NOTE: Using single worker because in-memory state_manager cannot be shared across workers. +# Multiple workers would require Redis or another shared state store. +CMD ["uv", "run", "python", "-m", "uvicorn", "app.main:socket_app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] \ No newline at end of file diff --git a/backend/app/api/routes/games.py b/backend/app/api/routes/games.py index c1d7fbd..ac936f1 100644 --- a/backend/app/api/routes/games.py +++ b/backend/app/api/routes/games.py @@ -54,6 +54,13 @@ class CreateGameResponse(BaseModel): status: str +class QuickCreateRequest(BaseModel): + """Optional request model for quick-create with custom teams""" + + home_team_id: int | None = Field(None, description="Home team ID (uses default if not provided)") + away_team_id: int | None = Field(None, description="Away team ID (uses default if not provided)") + + class LineupPlayerRequest(BaseModel): """Single player in lineup request""" @@ -326,15 +333,22 @@ async def create_game(request: CreateGameRequest): @router.post("/quick-create", response_model=CreateGameResponse) -async def quick_create_game(user: dict | None = Depends(get_current_user_optional)): +async def quick_create_game( + request: QuickCreateRequest | None = None, + user: dict | None = Depends(get_current_user_optional), +): """ - Quick-create endpoint for testing - creates a game with pre-configured lineups. + Quick-create endpoint for testing - creates a game with auto-generated lineups. - Uses the lineup configuration from the most recent game (Team 35 vs Team 38). - This eliminates the 2-minute lineup configuration process during testing. + If home_team_id and away_team_id are provided, fetches rosters from SBA API + and auto-generates lineups. Otherwise uses default teams (35 vs 38) with + pre-configured lineups. The authenticated user's discord_id is stored as creator_discord_id, allowing - them to control the home team regardless of actual team ownership. + them to control teams regardless of actual team ownership. + + Args: + request: Optional request body with custom team IDs Returns: CreateGameResponse with game_id @@ -342,21 +356,32 @@ async def quick_create_game(user: dict | None = Depends(get_current_user_optiona try: # Generate game ID game_id = uuid4() - - # Use real team data from most recent game - home_team_id = 35 - away_team_id = 38 league_id = "sba" + # Determine team IDs + use_custom_teams = ( + request is not None + and request.home_team_id is not None + and request.away_team_id is not None + ) + + if use_custom_teams: + home_team_id = request.home_team_id + away_team_id = request.away_team_id + else: + # Default demo teams + home_team_id = 35 + away_team_id = 38 + # Get creator's discord_id from authenticated user creator_discord_id = user.get("discord_id") if user else None logger.info( - f"Quick-creating game {game_id}: {home_team_id} vs {away_team_id} (creator: {creator_discord_id})" + f"Quick-creating game {game_id}: {home_team_id} vs {away_team_id} " + f"(creator: {creator_discord_id}, custom_teams: {use_custom_teams})" ) # Create game in state manager - # Creator controls both teams for solo testing state = await state_manager.create_game( game_id=game_id, league_id=league_id, @@ -376,53 +401,59 @@ async def quick_create_game(user: dict | None = Depends(get_current_user_optiona visibility="public", ) - # Submit home lineup (Team 35) - home_lineup_data = [ - {"player_id": 1417, "position": "C", "batting_order": 1}, - {"player_id": 1186, "position": "1B", "batting_order": 2}, - {"player_id": 1381, "position": "2B", "batting_order": 3}, - {"player_id": 1576, "position": "3B", "batting_order": 4}, - {"player_id": 1242, "position": "SS", "batting_order": 5}, - {"player_id": 1600, "position": "LF", "batting_order": 6}, - {"player_id": 1675, "position": "CF", "batting_order": 7}, - {"player_id": 1700, "position": "RF", "batting_order": 8}, - {"player_id": 1759, "position": "DH", "batting_order": 9}, - {"player_id": 1948, "position": "P", "batting_order": None}, - ] + if use_custom_teams: + # Fetch rosters from SBA API and build lineups dynamically + await _build_lineup_from_roster(game_id, home_team_id, season=13) + await _build_lineup_from_roster(game_id, away_team_id, season=13) + else: + # Use pre-configured demo lineups + # Submit home lineup (Team 35) + home_lineup_data = [ + {"player_id": 1417, "position": "C", "batting_order": 1}, + {"player_id": 1186, "position": "1B", "batting_order": 2}, + {"player_id": 1381, "position": "2B", "batting_order": 3}, + {"player_id": 1576, "position": "3B", "batting_order": 4}, + {"player_id": 1242, "position": "SS", "batting_order": 5}, + {"player_id": 1600, "position": "LF", "batting_order": 6}, + {"player_id": 1675, "position": "CF", "batting_order": 7}, + {"player_id": 1700, "position": "RF", "batting_order": 8}, + {"player_id": 1759, "position": "DH", "batting_order": 9}, + {"player_id": 1948, "position": "P", "batting_order": None}, + ] - for player in home_lineup_data: - await lineup_service.add_sba_player_to_lineup( - game_id=game_id, - team_id=home_team_id, - player_id=player["player_id"], - position=player["position"], - batting_order=player["batting_order"], - is_starter=True, - ) + for player in home_lineup_data: + await lineup_service.add_sba_player_to_lineup( + game_id=game_id, + team_id=home_team_id, + player_id=player["player_id"], + position=player["position"], + batting_order=player["batting_order"], + is_starter=True, + ) - # Submit away lineup (Team 38) - away_lineup_data = [ - {"player_id": 1080, "position": "C", "batting_order": 1}, - {"player_id": 1148, "position": "1B", "batting_order": 2}, - {"player_id": 1166, "position": "2B", "batting_order": 3}, - {"player_id": 1513, "position": "3B", "batting_order": 4}, - {"player_id": 1209, "position": "SS", "batting_order": 5}, - {"player_id": 1735, "position": "LF", "batting_order": 6}, - {"player_id": 1665, "position": "CF", "batting_order": 7}, - {"player_id": 1961, "position": "RF", "batting_order": 8}, - {"player_id": 1980, "position": "DH", "batting_order": 9}, - {"player_id": 2005, "position": "P", "batting_order": None}, - ] + # Submit away lineup (Team 38) + away_lineup_data = [ + {"player_id": 1080, "position": "C", "batting_order": 1}, + {"player_id": 1148, "position": "1B", "batting_order": 2}, + {"player_id": 1166, "position": "2B", "batting_order": 3}, + {"player_id": 1513, "position": "3B", "batting_order": 4}, + {"player_id": 1209, "position": "SS", "batting_order": 5}, + {"player_id": 1735, "position": "LF", "batting_order": 6}, + {"player_id": 1665, "position": "CF", "batting_order": 7}, + {"player_id": 1961, "position": "RF", "batting_order": 8}, + {"player_id": 1980, "position": "DH", "batting_order": 9}, + {"player_id": 2005, "position": "P", "batting_order": None}, + ] - for player in away_lineup_data: - await lineup_service.add_sba_player_to_lineup( - game_id=game_id, - team_id=away_team_id, - player_id=player["player_id"], - position=player["position"], - batting_order=player["batting_order"], - is_starter=True, - ) + for player in away_lineup_data: + await lineup_service.add_sba_player_to_lineup( + game_id=game_id, + team_id=away_team_id, + player_id=player["player_id"], + position=player["position"], + batting_order=player["batting_order"], + is_starter=True, + ) # Start the game await game_engine.start_game(game_id) @@ -440,6 +471,126 @@ async def quick_create_game(user: dict | None = Depends(get_current_user_optiona raise HTTPException(status_code=500, detail=f"Failed to quick-create game: {str(e)}") +async def _build_lineup_from_roster(game_id: UUID, team_id: int, season: int = 13) -> None: + """ + Build a lineup from SBA API roster data. + + Fetches the team roster and assigns players to positions based on their + listed positions. Uses a simple algorithm to fill all positions: + - First player at each position gets assigned + - DH filled with extra player if available + - Batting order assigned by position priority + + Args: + game_id: Game UUID + team_id: Team ID to fetch roster for + season: Season number (default 13) + """ + # Fetch roster from SBA API + roster = await sba_api_client.get_roster(team_id=team_id, season=season) + + if not roster: + raise HTTPException( + status_code=400, + detail=f"No roster found for team {team_id} in season {season}", + ) + + # Position priority for lineup building + # Standard DH lineup: 9 fielders + DH, pitcher doesn't bat + positions_needed = ["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "P"] + lineup_positions: dict[str, dict] = {} + used_player_ids: set[int] = set() + + # First pass: assign players to their primary positions + for player in roster: + player_id = player.get("id") + player_positions = player.get("position", "").split("/") + + if not player_id or player_id in used_player_ids: + continue + + # Try to assign to first matching needed position + for pos in player_positions: + pos = pos.strip().upper() + if pos in positions_needed and pos not in lineup_positions: + lineup_positions[pos] = { + "player_id": player_id, + "position": pos, + } + used_player_ids.add(player_id) + break + + # Check we have all required positions + missing = [p for p in positions_needed if p not in lineup_positions] + if missing: + # Second pass: try to fill missing positions with any available player + for pos in missing: + for player in roster: + player_id = player.get("id") + if player_id and player_id not in used_player_ids: + lineup_positions[pos] = { + "player_id": player_id, + "position": pos, + } + used_player_ids.add(player_id) + break + + # Still missing? Raise error + still_missing = [p for p in positions_needed if p not in lineup_positions] + if still_missing: + raise HTTPException( + status_code=400, + detail=f"Could not fill positions {still_missing} for team {team_id}", + ) + + # Find DH - use next available player not in lineup + dh_player = None + for player in roster: + player_id = player.get("id") + if player_id and player_id not in used_player_ids: + dh_player = {"player_id": player_id, "position": "DH"} + used_player_ids.add(player_id) + break + + if not dh_player: + raise HTTPException( + status_code=400, + detail=f"Not enough players for DH on team {team_id}", + ) + + # Build final lineup with batting order + # Batting order: C, 1B, 2B, 3B, SS, LF, CF, RF, DH + batting_order_positions = ["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"] + + for order, pos in enumerate(batting_order_positions, start=1): + if pos == "DH": + player_data = dh_player + else: + player_data = lineup_positions[pos] + + await lineup_service.add_sba_player_to_lineup( + game_id=game_id, + team_id=team_id, + player_id=player_data["player_id"], + position=player_data["position"], + batting_order=order, + is_starter=True, + ) + + # Add pitcher (no batting order in DH lineup) + pitcher_data = lineup_positions["P"] + await lineup_service.add_sba_player_to_lineup( + game_id=game_id, + team_id=team_id, + player_id=pitcher_data["player_id"], + position="P", + batting_order=None, + is_starter=True, + ) + + logger.info(f"Built lineup for team {team_id} with {len(batting_order_positions) + 1} players") + + @router.post("/{game_id}/lineups", response_model=SubmitLineupsResponse) async def submit_lineups(game_id: str, request: SubmitLineupsRequest): """ diff --git a/backend/app/api/routes/schedule.py b/backend/app/api/routes/schedule.py new file mode 100644 index 0000000..087b60c --- /dev/null +++ b/backend/app/api/routes/schedule.py @@ -0,0 +1,79 @@ +""" +SBA Schedule API routes. + +Provides endpoints to fetch current season/week and scheduled games +from the SBA production API. +""" + +import logging +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app.services.sba_api_client import sba_api_client + +logger = logging.getLogger(f"{__name__}.schedule") + +router = APIRouter() + + +@router.get("/current") +async def get_current() -> dict[str, Any]: + """ + Get current season and week from SBA API. + + Returns: + Dictionary with season and week numbers + + Example response: + {"season": 13, "week": 12} + """ + try: + return await sba_api_client.get_current() + except Exception as e: + logger.error(f"Failed to get current season/week: {e}") + raise HTTPException( + status_code=502, detail="Failed to fetch current season/week from SBA API" + ) + + +@router.get("/games") +async def get_schedule_games( + season: int, + week: int, +) -> list[dict[str, Any]]: + """ + Get scheduled games for a specific week from SBA API. + + Args: + season: Season number (e.g., 13) + week: Week number within the season + + Returns: + List of game dictionaries with team matchup info + + Example response: + [ + { + "id": 12345, + "season": 13, + "week": 12, + "home_team_id": 35, + "away_team_id": 42, + "home_abbrev": "STL", + "away_abbrev": "CHC", + ... + } + ] + """ + try: + games = await sba_api_client.get_schedule_games( + season=season, week_start=week, week_end=week + ) + return games + except Exception as e: + logger.error(f"Failed to get schedule games for S{season} W{week}: {e}") + raise HTTPException( + status_code=502, + detail=f"Failed to fetch schedule games from SBA API for season {season}, week {week}", + ) diff --git a/backend/app/core/state_manager.py b/backend/app/core/state_manager.py index 65d90ea..f14db6e 100644 --- a/backend/app/core/state_manager.py +++ b/backend/app/core/state_manager.py @@ -40,6 +40,29 @@ class StateManager: - Recover game states from database on demand This class uses dictionaries for O(1) lookups of game state by game_id. + + TODO: Redis Migration for Multi-Worker Scalability + ================================================= + Current implementation stores state in Python dicts, limiting to single worker. + For multi-worker deployment (high concurrency), migrate to Redis: + + Storage mapping: + _states[game_id] → Redis key: game:{id}:state (JSON) + _lineups[game_id][team_id] → Redis key: game:{id}:lineup:{team_id} (JSON) + _last_access[game_id] → Redis key TTL (auto-expiry) + _pending_decisions → Redis pub/sub for cross-worker coordination + + Implementation steps: + 1. Add async Redis client (aioredis or redis-py async) + 2. Create RedisStateManager implementing same interface + 3. Serialize GameState/TeamLineupState via .model_dump_json() + 4. Add Redis pub/sub for WebSocket broadcast across workers + 5. Update Dockerfile to use --workers N + + Performance impact: ~1ms latency per Redis call (vs ~1μs in-memory) + Still well under 500ms response target. + + Note: Redis is already in docker-compose for rate limiting. """ def __init__(self): @@ -522,8 +545,14 @@ class StateManager: f"{runner_count} runners on base, {state.outs} outs" ) else: + # No completed plays - but game is active, so start decision workflow + if state.status == "active": + state.decision_phase = "awaiting_defensive" logger.debug("No completed plays found - initializing fresh state") else: + # No plays at all - if game is active, start decision workflow + if state.status == "active": + state.decision_phase = "awaiting_defensive" logger.debug("No plays found - initializing fresh state") # Count runners on base diff --git a/backend/app/main.py b/backend/app/main.py index ae96608..b719fc5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from sqlalchemy.exc import SQLAlchemyError -from app.api.routes import auth, games, health, teams +from app.api.routes import auth, games, health, schedule, teams from app.config import get_settings from app.core.exceptions import ( DatabaseError, @@ -330,6 +330,7 @@ app.include_router(health.router, prefix="/api", tags=["health"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(games.router, prefix="/api/games", tags=["games"]) app.include_router(teams.router, prefix="/api/teams", tags=["teams"]) +app.include_router(schedule.router, prefix="/api/schedule", tags=["schedule"]) @app.get("/") diff --git a/backend/app/services/sba_api_client.py b/backend/app/services/sba_api_client.py index 7a43b8f..56eb925 100644 --- a/backend/app/services/sba_api_client.py +++ b/backend/app/services/sba_api_client.py @@ -306,6 +306,79 @@ class SbaApiClient: logger.info(f"Loaded {len(results)}/{len(player_ids)} players") return results + async def get_current(self) -> dict[str, Any]: + """ + Get current season and week from SBA API. + + Returns: + Dictionary with 'season' and 'week' keys + + Example: + current = await client.get_current() + print(f"Season {current['season']}, Week {current['week']}") + """ + url = f"{self.base_url}/current" + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get(url, headers=self._get_headers()) + response.raise_for_status() + + data = response.json() + logger.info(f"Current: Season {data.get('season')}, Week {data.get('week')}") + return data + + except httpx.HTTPError as e: + logger.error(f"Failed to fetch current season/week: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error fetching current: {e}") + raise + + async def get_schedule_games( + self, season: int, week_start: int, week_end: int + ) -> list[dict[str, Any]]: + """ + Get scheduled games for a week range from SBA API. + + Args: + season: Season number (e.g., 13) + week_start: Starting week number + week_end: Ending week number (inclusive) + + Returns: + List of game dictionaries with team info + + Example: + games = await client.get_schedule_games(season=13, week_start=12, week_end=12) + for game in games: + print(f"{game['away_abbrev']} @ {game['home_abbrev']}") + """ + url = f"{self.base_url}/games" + params = {"season": season, "week_start": week_start, "week_end": week_end} + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get( + url, headers=self._get_headers(), params=params + ) + response.raise_for_status() + + data = response.json() + games = data.get("games", data) if isinstance(data, dict) else data + + logger.info( + f"Loaded {len(games)} scheduled games for S{season} W{week_start}-{week_end}" + ) + return games + + except httpx.HTTPError as e: + logger.error(f"Failed to fetch schedule games: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error fetching schedule games: {e}") + raise + # Singleton instance sba_api_client = SbaApiClient() diff --git a/frontend-sba/components/Schedule/GameCard.vue b/frontend-sba/components/Schedule/GameCard.vue new file mode 100644 index 0000000..6786c46 --- /dev/null +++ b/frontend-sba/components/Schedule/GameCard.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend-sba/components/Schedule/WeekNavigation.vue b/frontend-sba/components/Schedule/WeekNavigation.vue new file mode 100644 index 0000000..4b76fb8 --- /dev/null +++ b/frontend-sba/components/Schedule/WeekNavigation.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend-sba/composables/useSchedule.ts b/frontend-sba/composables/useSchedule.ts new file mode 100644 index 0000000..c340169 --- /dev/null +++ b/frontend-sba/composables/useSchedule.ts @@ -0,0 +1,191 @@ +/** + * Schedule Composable + * + * Manages SBA schedule data fetching and week navigation. + * Fetches current season/week and scheduled games from SBA API. + */ + +import type { SbaCurrent, SbaScheduledGame } from '~/types' + +export function useSchedule() { + const config = useRuntimeConfig() + const apiUrl = useApiUrl() + + // State - use useState for SSR-safe persistence across hydration + // Regular ref() creates new state on each call, breaking after hydration + const current = useState('schedule-current', () => null) + const selectedSeason = useState('schedule-season', () => 0) + const selectedWeek = useState('schedule-week', () => 0) + const games = useState('schedule-games', () => []) + const loading = useState('schedule-loading', () => false) + const error = useState('schedule-error', () => null) + + // Track if we've initialized + const initialized = useState('schedule-initialized', () => false) + + /** + * Get headers for SSR-compatible requests + */ + function getHeaders(): Record { + const headers: Record = {} + if (import.meta.server) { + const event = useRequestEvent() + const cookieHeader = event?.node.req.headers.cookie + if (cookieHeader) { + headers['Cookie'] = cookieHeader + } + } + return headers + } + + /** + * Fetch current season and week from SBA API + */ + async function fetchCurrent(): Promise { + try { + error.value = null + const response = await $fetch(`${apiUrl}/api/schedule/current`, { + credentials: 'include', + headers: getHeaders(), + }) + + current.value = response + // Initialize selected to current if not already set + if (!initialized.value) { + selectedSeason.value = response.season + selectedWeek.value = response.week + initialized.value = true + } + + console.log('[useSchedule] Current:', response) + } catch (err: any) { + console.error('[useSchedule] Failed to fetch current:', err) + error.value = err.data?.detail || err.message || 'Failed to fetch current season/week' + throw err + } + } + + /** + * Fetch games for the selected week + */ + async function fetchGames(): Promise { + if (!selectedSeason.value || !selectedWeek.value) { + console.warn('[useSchedule] No season/week selected') + return + } + + try { + loading.value = true + error.value = null + + const response = await $fetch( + `${apiUrl}/api/schedule/games`, + { + credentials: 'include', + headers: getHeaders(), + params: { + season: selectedSeason.value, + week: selectedWeek.value, + }, + } + ) + + games.value = response + console.log(`[useSchedule] Loaded ${response.length} games for S${selectedSeason.value} W${selectedWeek.value}`) + } catch (err: any) { + console.error('[useSchedule] Failed to fetch games:', err) + error.value = err.data?.detail || err.message || 'Failed to fetch schedule games' + games.value = [] + } finally { + loading.value = false + } + } + + /** + * Initialize schedule data (fetch current + games) + */ + async function initialize(): Promise { + try { + loading.value = true + await fetchCurrent() + await fetchGames() + } catch { + // Error already set in fetchCurrent + } finally { + loading.value = false + } + } + + /** + * Navigate to next week + */ + function nextWeek(): void { + selectedWeek.value++ + fetchGames() + } + + /** + * Navigate to previous week + */ + function prevWeek(): void { + if (selectedWeek.value > 1) { + selectedWeek.value-- + fetchGames() + } + } + + /** + * Jump back to current week + */ + function goToCurrentWeek(): void { + if (current.value) { + selectedSeason.value = current.value.season + selectedWeek.value = current.value.week + fetchGames() + } + } + + /** + * Set specific week (useful for direct navigation) + */ + function setWeek(week: number): void { + if (week >= 1) { + selectedWeek.value = week + fetchGames() + } + } + + /** + * Check if currently viewing the current week + */ + const isCurrentWeek = computed(() => { + if (!current.value) return false + return ( + selectedSeason.value === current.value.season && + selectedWeek.value === current.value.week + ) + }) + + return { + // State - return refs directly for proper reactivity in templates + current, + selectedSeason, + selectedWeek, + games, + loading, + error, + initialized, + + // Computed + isCurrentWeek, + + // Actions + initialize, + fetchCurrent, + fetchGames, + nextWeek, + prevWeek, + goToCurrentWeek, + setWeek, + } +} diff --git a/frontend-sba/pages/index.vue b/frontend-sba/pages/index.vue index 1d31641..8bd313a 100755 --- a/frontend-sba/pages/index.vue +++ b/frontend-sba/pages/index.vue @@ -28,6 +28,17 @@
- -
+ +

Loading games...

- -
+ +

Failed to load games

{{ error }}

- + +
+ + + + +
+
+

Loading schedule...

+
+ + +
+

Failed to load schedule

+

{{ schedule.error.value }}

+ +
+ + +
+
+ +
+
+ {{ group.awayTeam.abbrev }} @ {{ group.homeTeam.abbrev }} +
+
+ + +
+
+ G{{ idx + 1 }} + + + + +
+
+
+
+ + +
+ + + +

+ No Games Scheduled +

+

+ No games are scheduled for this week. +

+
+
+ +
@@ -146,6 +260,7 @@
+
@@ -214,7 +329,7 @@ definePageMeta({ middleware: ['auth'], // Require authentication }) -const activeTab = ref<'active' | 'completed'>('active') +const activeTab = ref<'schedule' | 'active' | 'completed'>('schedule') const config = useRuntimeConfig() const authStore = useAuthStore() const router = useRouter() @@ -224,6 +339,49 @@ const loading = ref(true) const error = ref(null) const isCreatingQuickGame = ref(false) +// Schedule composable +const schedule = useSchedule() + +// Group schedule games by matchup (same away_team + home_team) +// Each group contains games sorted by game_id ascending +interface ScheduleGroup { + key: string + awayTeam: { id: number; abbrev: string; sname: string } + homeTeam: { id: number; abbrev: string; sname: string } + games: typeof schedule.games.value +} + +const groupedScheduleGames = computed(() => { + const gamesArray = schedule.games.value + if (!gamesArray || gamesArray.length === 0) return [] + + // Group by matchup key (away_team_id-home_team_id) + const groups = new Map() + + for (const game of gamesArray) { + const key = `${game.away_team.id}-${game.home_team.id}` + + if (!groups.has(key)) { + groups.set(key, { + key, + awayTeam: game.away_team, + homeTeam: game.home_team, + games: [], + }) + } + + groups.get(key)!.games.push(game) + } + + // Sort games within each group by game_id ascending + for (const group of groups.values()) { + group.games.sort((a, b) => a.id - b.id) + } + + // Convert to array and sort groups by first game's id + return Array.from(groups.values()).sort((a, b) => a.games[0].id - b.games[0].id) +}) + // Quick-create a demo game with pre-configured lineups async function handleQuickCreate() { try { @@ -252,6 +410,48 @@ async function handleQuickCreate() { } } +// Create a game from a scheduled matchup +async function handlePlayScheduledGame(homeTeamId: number, awayTeamId: number) { + try { + isCreatingQuickGame.value = true + error.value = null + + console.log(`[Games Page] Creating game: ${awayTeamId} @ ${homeTeamId}`) + + const response = await $fetch<{ game_id: string; message: string; status: string }>( + `${config.public.apiUrl}/api/games/quick-create`, + { + method: 'POST', + credentials: 'include', + body: { + home_team_id: homeTeamId, + away_team_id: awayTeamId, + }, + } + ) + + console.log('[Games Page] Created game from schedule:', response) + + // Redirect to game page + router.push(`/games/${response.game_id}`) + } catch (err: any) { + console.error('[Games Page] Failed to create game from schedule:', err) + error.value = err.data?.detail || err.message || 'Failed to create game' + } finally { + isCreatingQuickGame.value = false + } +} + +// Handle tab change +function handleTabChange(tab: 'schedule' | 'active' | 'completed') { + activeTab.value = tab + + // Initialize schedule data when switching to schedule tab + if (tab === 'schedule' && !schedule.initialized.value) { + schedule.initialize() + } +} + // Fetch games - uses internal URL for SSR, public URL for client const apiUrl = useApiUrl() const { data: games, pending, error: fetchError, refresh } = await useAsyncData( @@ -291,9 +491,14 @@ const completedGames = computed(() => { return games.value?.filter(g => g.status === 'completed' || g.status === 'final') || [] }) -// Re-fetch on client if data is stale (handles post-OAuth client-side navigation) +// Initialize schedule on mount (since it's the default tab) onMounted(async () => { - // Only re-fetch if we have no games but are authenticated + // Initialize schedule data since Schedule is the default tab + if (activeTab.value === 'schedule') { + schedule.initialize() + } + + // Only re-fetch games if we have no games but are authenticated // This handles the case where client-side navigation doesn't trigger SSR if ((!games.value || games.value.length === 0) && authStore.isAuthenticated) { console.log('[Games Page] No games on mount, re-fetching...') diff --git a/frontend-sba/types/index.ts b/frontend-sba/types/index.ts index 9454523..3ecb6ec 100644 --- a/frontend-sba/types/index.ts +++ b/frontend-sba/types/index.ts @@ -110,3 +110,11 @@ export type { RefreshTokenRequest, RefreshTokenResponse, } from './api' + +// Schedule types +export type { + SbaCurrent, + SbaScheduledGame, + GetCurrentResponse, + GetScheduleGamesResponse, +} from './schedule' diff --git a/frontend-sba/types/schedule.ts b/frontend-sba/types/schedule.ts new file mode 100644 index 0000000..2149a19 --- /dev/null +++ b/frontend-sba/types/schedule.ts @@ -0,0 +1,74 @@ +/** + * SBA Schedule Types + * + * Types for schedule data from SBA production API. + */ + +/** + * Current season and week from /current endpoint + */ +export interface SbaCurrent { + id: number + season: number + week: number + freeze: boolean + transcount: number + bstatcount: number + pstatcount: number + bet_week: number + trade_deadline: number + pick_trade_start: number + pick_trade_end: number + playoffs_begin: number + injury_count: number +} + +/** + * Team info nested in scheduled game + */ +export interface SbaScheduledTeam { + id: number + abbrev: string + sname: string // Short name e.g., "Cardinals" + lname: string // Long name e.g., "St. Louis Cardinals" + thumbnail?: string + color?: string +} + +/** + * Scheduled game from /games endpoint + * + * Games are returned with nested away_team and home_team objects. + */ +export interface SbaScheduledGame { + // Game identity + id: number + season: number + week: number + game_num: number | null // Game number within the week + season_type: string // 'regular', 'playoff', etc. + + // Nested team objects + away_team: SbaScheduledTeam + home_team: SbaScheduledTeam + + // Scores (null if not played) + away_score: number | null + home_score: number | null + + // Other fields + scorecard_url: string | null + + // Computed helper properties (for backward compatibility with simpler interface) + // These are accessed via getter functions in the component +} + +/** + * Schedule API response types + */ +export interface GetCurrentResponse extends SbaCurrent {} + +export interface GetScheduleGamesResponse { + games: SbaScheduledGame[] + count?: number +}