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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-14 23:39:31 -06:00
parent 403ba7c90f
commit fbbb1cc5da
12 changed files with 1036 additions and 67 deletions

View File

@ -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"]
# 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"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,87 @@
<template>
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition p-4 border border-gray-200">
<!-- Game Badge -->
<div class="flex justify-between items-start mb-3">
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded">
{{ game.season_type || 'Regular' }}
</span>
<span v-if="hasScore" class="text-xs text-green-600 font-medium">
Completed
</span>
</div>
<!-- Matchup Display -->
<div class="space-y-2 mb-4">
<!-- Away Team -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400 w-10">Away</span>
<span class="font-semibold text-gray-900">
{{ game.away_team.abbrev }}
</span>
<span class="text-sm text-gray-600 hidden sm:inline">
{{ game.away_team.sname }}
</span>
</div>
<span v-if="game.away_score !== null" class="text-lg font-bold tabular-nums">
{{ game.away_score }}
</span>
</div>
<!-- Home Team -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400 w-10">Home</span>
<span class="font-semibold text-gray-900">
{{ game.home_team.abbrev }}
</span>
<span class="text-sm text-gray-600 hidden sm:inline">
{{ game.home_team.sname }}
</span>
</div>
<span v-if="game.home_score !== null" class="text-lg font-bold tabular-nums">
{{ game.home_score }}
</span>
</div>
</div>
<!-- Play This Game Button -->
<button
@click="handlePlayGame"
:disabled="isCreating"
class="w-full px-4 py-2 bg-primary hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium rounded-lg transition disabled:cursor-not-allowed"
>
{{ isCreating ? 'Creating...' : 'Play This Game' }}
</button>
</div>
</template>
<script setup lang="ts">
import type { SbaScheduledGame } from '~/types'
interface Props {
game: SbaScheduledGame
}
const props = defineProps<Props>()
const emit = defineEmits<{
play: [homeTeamId: number, awayTeamId: number]
}>()
const isCreating = ref(false)
// Check if game has scores (completed)
const hasScore = computed(() => {
return props.game.away_score !== null && props.game.home_score !== null
})
function handlePlayGame() {
emit('play', props.game.home_team.id, props.game.away_team.id)
}
// Expose isCreating for parent to control
defineExpose({
setCreating: (value: boolean) => { isCreating.value = value }
})
</script>

View File

@ -0,0 +1,69 @@
<template>
<div class="flex items-center justify-between bg-gray-50 rounded-lg px-4 py-3">
<!-- Previous Week Button -->
<button
@click="emit('prev')"
:disabled="selectedWeek <= 1"
class="flex items-center gap-1 px-3 py-2 text-gray-700 hover:bg-gray-200 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span class="hidden sm:inline">Prev</span>
</button>
<!-- Week Display -->
<div class="flex items-center gap-3">
<div class="text-center">
<div class="text-lg font-bold text-gray-900">
Week {{ selectedWeek }}
</div>
<div class="text-sm text-gray-500">
Season {{ selectedSeason }}
</div>
</div>
<!-- Current Week Button -->
<button
v-if="!isCurrentWeek"
@click="emit('goToCurrent')"
class="px-3 py-1.5 text-sm bg-primary text-white rounded-lg hover:bg-blue-700 transition"
>
Current Week
</button>
<span
v-else
class="px-3 py-1.5 text-sm bg-green-100 text-green-800 rounded-lg font-medium"
>
Current
</span>
</div>
<!-- Next Week Button -->
<button
@click="emit('next')"
class="flex items-center gap-1 px-3 py-2 text-gray-700 hover:bg-gray-200 rounded-lg transition"
>
<span class="hidden sm:inline">Next</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
interface Props {
selectedSeason: number
selectedWeek: number
isCurrentWeek: boolean
}
defineProps<Props>()
const emit = defineEmits<{
prev: []
next: []
goToCurrent: []
}>()
</script>

View File

@ -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<SbaCurrent | null>('schedule-current', () => null)
const selectedSeason = useState<number>('schedule-season', () => 0)
const selectedWeek = useState<number>('schedule-week', () => 0)
const games = useState<SbaScheduledGame[]>('schedule-games', () => [])
const loading = useState<boolean>('schedule-loading', () => false)
const error = useState<string | null>('schedule-error', () => null)
// Track if we've initialized
const initialized = useState<boolean>('schedule-initialized', () => false)
/**
* Get headers for SSR-compatible requests
*/
function getHeaders(): Record<string, string> {
const headers: Record<string, string> = {}
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<void> {
try {
error.value = null
const response = await $fetch<SbaCurrent>(`${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<void> {
if (!selectedSeason.value || !selectedWeek.value) {
console.warn('[useSchedule] No season/week selected')
return
}
try {
loading.value = true
error.value = null
const response = await $fetch<SbaScheduledGame[]>(
`${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<void> {
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,
}
}

View File

@ -28,6 +28,17 @@
<!-- Tabs -->
<div class="mb-6 border-b border-gray-200">
<nav class="flex space-x-8">
<button
:class="[
'py-4 px-1 border-b-2 font-medium text-sm transition',
activeTab === 'schedule'
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
@click="handleTabChange('schedule')"
>
Schedule
</button>
<button
:class="[
'py-4 px-1 border-b-2 font-medium text-sm transition',
@ -35,7 +46,7 @@
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
@click="activeTab = 'active'"
@click="handleTabChange('active')"
>
Active Games
</button>
@ -46,21 +57,21 @@
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
@click="activeTab = 'completed'"
@click="handleTabChange('completed')"
>
Completed
</button>
</nav>
</div>
<!-- Loading State -->
<div v-if="loading" class="bg-white rounded-lg shadow-md p-12 text-center">
<!-- Loading State (for games list) -->
<div v-if="loading && activeTab !== 'schedule'" class="bg-white rounded-lg shadow-md p-12 text-center">
<div class="w-16 h-16 mx-auto mb-4 border-4 border-primary border-t-transparent rounded-full animate-spin"/>
<p class="text-gray-900 font-semibold">Loading games...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-lg p-6">
<!-- Error State (for games list) -->
<div v-else-if="error && activeTab !== 'schedule'" class="bg-red-50 border border-red-200 rounded-lg p-6">
<p class="text-red-800 font-semibold">Failed to load games</p>
<p class="text-red-600 text-sm mt-2">{{ error }}</p>
<button
@ -71,7 +82,110 @@
</button>
</div>
<!-- Games List -->
<!-- Schedule Tab -->
<div v-else-if="activeTab === 'schedule'">
<!-- Week Navigation -->
<ScheduleWeekNavigation
:selected-season="schedule.selectedSeason.value"
:selected-week="schedule.selectedWeek.value"
:is-current-week="schedule.isCurrentWeek.value"
@prev="schedule.prevWeek"
@next="schedule.nextWeek"
@go-to-current="schedule.goToCurrentWeek"
class="mb-6"
/>
<!-- Schedule Loading -->
<div v-if="schedule.loading.value" class="bg-white rounded-lg shadow-md p-12 text-center">
<div class="w-16 h-16 mx-auto mb-4 border-4 border-primary border-t-transparent rounded-full animate-spin"/>
<p class="text-gray-900 font-semibold">Loading schedule...</p>
</div>
<!-- Schedule Error -->
<div v-else-if="schedule.error.value" class="bg-red-50 border border-red-200 rounded-lg p-6">
<p class="text-red-800 font-semibold">Failed to load schedule</p>
<p class="text-red-600 text-sm mt-2">{{ schedule.error.value }}</p>
<button
class="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
@click="schedule.fetchGames"
>
Retry
</button>
</div>
<!-- Schedule Games - Grouped by Matchup (horizontal groups, vertical games) -->
<div v-if="groupedScheduleGames.length > 0" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div
v-for="group in groupedScheduleGames"
:key="group.key"
class="bg-white rounded-lg shadow-md p-3 border border-gray-200"
>
<!-- Matchup Header (compact) -->
<div class="text-center mb-3 pb-2 border-b border-gray-200">
<div class="font-bold text-sm text-gray-900">
{{ group.awayTeam.abbrev }} @ {{ group.homeTeam.abbrev }}
</div>
</div>
<!-- Games stacked vertically -->
<div class="space-y-2">
<div
v-for="(game, idx) in group.games"
:key="game.id"
class="flex items-center gap-2 p-2 bg-gray-50 rounded border border-gray-100"
>
<span class="text-xs text-gray-500 w-6">G{{ idx + 1 }}</span>
<!-- Completed game: show score and "Final" badge -->
<template v-if="game.away_score !== null && game.home_score !== null">
<span class="text-xs font-semibold text-gray-700 flex-1">
{{ game.away_score }}-{{ game.home_score }}
</span>
<span class="px-2 py-1 text-xs bg-gray-200 text-gray-600 font-medium rounded">
Final
</span>
</template>
<!-- Incomplete game: show Play button -->
<template v-else>
<span class="flex-1"></span>
<button
@click="handlePlayScheduledGame(game.home_team.id, game.away_team.id)"
: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"
>
Play
</button>
</template>
</div>
</div>
</div>
</div>
<!-- Empty Schedule State -->
<div v-if="!schedule.loading.value && !schedule.error.value && (!schedule.games.value || schedule.games.value.length === 0)" class="bg-white rounded-lg shadow-md p-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mx-auto text-gray-400 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<h3 class="text-xl font-bold text-gray-900 mb-2">
No Games Scheduled
</h3>
<p class="text-gray-600">
No games are scheduled for this week.
</p>
</div>
</div>
<!-- Active Games Tab -->
<div v-else-if="activeTab === 'active'">
<!-- Games List -->
<div v-if="activeGames.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@ -146,6 +260,7 @@
</div>
</div>
<!-- Completed Games Tab -->
<div v-else-if="activeTab === 'completed'">
<!-- Completed Games List -->
<div v-if="completedGames.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@ -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<string | null>(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<ScheduleGroup[]>(() => {
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<string, ScheduleGroup>()
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...')

View File

@ -110,3 +110,11 @@ export type {
RefreshTokenRequest,
RefreshTokenResponse,
} from './api'
// Schedule types
export type {
SbaCurrent,
SbaScheduledGame,
GetCurrentResponse,
GetScheduleGamesResponse,
} from './schedule'

View File

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