From fbbb1cc5da0f06c1da5f1dfbb88f674620e9c1fa Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 14 Jan 2026 23:39:31 -0600 Subject: [PATCH] CLAUDE: Add SBA schedule integration with weekly matchup display Implements schedule viewing from SBA production API with week navigation and game creation from scheduled matchups. Groups games by team matchup horizontally with games stacked vertically for space efficiency. Backend: - Add schedule routes (/api/schedule/current, /api/schedule/games) - Add SBA API client methods for schedule data - Fix multi-worker state isolation (single worker for in-memory state) - Add Redis migration TODO for future scalability - Support custom team IDs in quick-create endpoint Frontend: - Add Schedule tab as default on home page - Week navigation with prev/next and "Current Week" jump - Horizontal group layout (2-6 columns responsive) - Completed games show score + "Final" badge (no Play button) - Incomplete games show "Play" button to create webapp game Co-Authored-By: Claude Opus 4.5 --- backend/Dockerfile | 4 +- backend/app/api/routes/games.py | 261 ++++++++++++++---- backend/app/api/routes/schedule.py | 79 ++++++ backend/app/core/state_manager.py | 29 ++ backend/app/main.py | 3 +- backend/app/services/sba_api_client.py | 73 +++++ frontend-sba/components/Schedule/GameCard.vue | 87 ++++++ .../components/Schedule/WeekNavigation.vue | 69 +++++ frontend-sba/composables/useSchedule.ts | 191 +++++++++++++ frontend-sba/pages/index.vue | 225 ++++++++++++++- frontend-sba/types/index.ts | 8 + frontend-sba/types/schedule.ts | 74 +++++ 12 files changed, 1036 insertions(+), 67 deletions(-) create mode 100644 backend/app/api/routes/schedule.py create mode 100644 frontend-sba/components/Schedule/GameCard.vue create mode 100644 frontend-sba/components/Schedule/WeekNavigation.vue create mode 100644 frontend-sba/composables/useSchedule.ts create mode 100644 frontend-sba/types/schedule.ts 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 +}