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:
parent
403ba7c90f
commit
fbbb1cc5da
@ -68,4 +68,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|||||||
CMD curl -f http://localhost:8000/api/health || exit 1
|
CMD curl -f http://localhost:8000/api/health || exit 1
|
||||||
|
|
||||||
# Run with production server
|
# 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"]
|
||||||
@ -54,6 +54,13 @@ class CreateGameResponse(BaseModel):
|
|||||||
status: str
|
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):
|
class LineupPlayerRequest(BaseModel):
|
||||||
"""Single player in lineup request"""
|
"""Single player in lineup request"""
|
||||||
|
|
||||||
@ -326,15 +333,22 @@ async def create_game(request: CreateGameRequest):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/quick-create", response_model=CreateGameResponse)
|
@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).
|
If home_team_id and away_team_id are provided, fetches rosters from SBA API
|
||||||
This eliminates the 2-minute lineup configuration process during testing.
|
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
|
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:
|
Returns:
|
||||||
CreateGameResponse with game_id
|
CreateGameResponse with game_id
|
||||||
@ -342,21 +356,32 @@ async def quick_create_game(user: dict | None = Depends(get_current_user_optiona
|
|||||||
try:
|
try:
|
||||||
# Generate game ID
|
# Generate game ID
|
||||||
game_id = uuid4()
|
game_id = uuid4()
|
||||||
|
|
||||||
# Use real team data from most recent game
|
|
||||||
home_team_id = 35
|
|
||||||
away_team_id = 38
|
|
||||||
league_id = "sba"
|
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
|
# Get creator's discord_id from authenticated user
|
||||||
creator_discord_id = user.get("discord_id") if user else None
|
creator_discord_id = user.get("discord_id") if user else None
|
||||||
|
|
||||||
logger.info(
|
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
|
# Create game in state manager
|
||||||
# Creator controls both teams for solo testing
|
|
||||||
state = await state_manager.create_game(
|
state = await state_manager.create_game(
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
league_id=league_id,
|
league_id=league_id,
|
||||||
@ -376,53 +401,59 @@ async def quick_create_game(user: dict | None = Depends(get_current_user_optiona
|
|||||||
visibility="public",
|
visibility="public",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Submit home lineup (Team 35)
|
if use_custom_teams:
|
||||||
home_lineup_data = [
|
# Fetch rosters from SBA API and build lineups dynamically
|
||||||
{"player_id": 1417, "position": "C", "batting_order": 1},
|
await _build_lineup_from_roster(game_id, home_team_id, season=13)
|
||||||
{"player_id": 1186, "position": "1B", "batting_order": 2},
|
await _build_lineup_from_roster(game_id, away_team_id, season=13)
|
||||||
{"player_id": 1381, "position": "2B", "batting_order": 3},
|
else:
|
||||||
{"player_id": 1576, "position": "3B", "batting_order": 4},
|
# Use pre-configured demo lineups
|
||||||
{"player_id": 1242, "position": "SS", "batting_order": 5},
|
# Submit home lineup (Team 35)
|
||||||
{"player_id": 1600, "position": "LF", "batting_order": 6},
|
home_lineup_data = [
|
||||||
{"player_id": 1675, "position": "CF", "batting_order": 7},
|
{"player_id": 1417, "position": "C", "batting_order": 1},
|
||||||
{"player_id": 1700, "position": "RF", "batting_order": 8},
|
{"player_id": 1186, "position": "1B", "batting_order": 2},
|
||||||
{"player_id": 1759, "position": "DH", "batting_order": 9},
|
{"player_id": 1381, "position": "2B", "batting_order": 3},
|
||||||
{"player_id": 1948, "position": "P", "batting_order": None},
|
{"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:
|
for player in home_lineup_data:
|
||||||
await lineup_service.add_sba_player_to_lineup(
|
await lineup_service.add_sba_player_to_lineup(
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
team_id=home_team_id,
|
team_id=home_team_id,
|
||||||
player_id=player["player_id"],
|
player_id=player["player_id"],
|
||||||
position=player["position"],
|
position=player["position"],
|
||||||
batting_order=player["batting_order"],
|
batting_order=player["batting_order"],
|
||||||
is_starter=True,
|
is_starter=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Submit away lineup (Team 38)
|
# Submit away lineup (Team 38)
|
||||||
away_lineup_data = [
|
away_lineup_data = [
|
||||||
{"player_id": 1080, "position": "C", "batting_order": 1},
|
{"player_id": 1080, "position": "C", "batting_order": 1},
|
||||||
{"player_id": 1148, "position": "1B", "batting_order": 2},
|
{"player_id": 1148, "position": "1B", "batting_order": 2},
|
||||||
{"player_id": 1166, "position": "2B", "batting_order": 3},
|
{"player_id": 1166, "position": "2B", "batting_order": 3},
|
||||||
{"player_id": 1513, "position": "3B", "batting_order": 4},
|
{"player_id": 1513, "position": "3B", "batting_order": 4},
|
||||||
{"player_id": 1209, "position": "SS", "batting_order": 5},
|
{"player_id": 1209, "position": "SS", "batting_order": 5},
|
||||||
{"player_id": 1735, "position": "LF", "batting_order": 6},
|
{"player_id": 1735, "position": "LF", "batting_order": 6},
|
||||||
{"player_id": 1665, "position": "CF", "batting_order": 7},
|
{"player_id": 1665, "position": "CF", "batting_order": 7},
|
||||||
{"player_id": 1961, "position": "RF", "batting_order": 8},
|
{"player_id": 1961, "position": "RF", "batting_order": 8},
|
||||||
{"player_id": 1980, "position": "DH", "batting_order": 9},
|
{"player_id": 1980, "position": "DH", "batting_order": 9},
|
||||||
{"player_id": 2005, "position": "P", "batting_order": None},
|
{"player_id": 2005, "position": "P", "batting_order": None},
|
||||||
]
|
]
|
||||||
|
|
||||||
for player in away_lineup_data:
|
for player in away_lineup_data:
|
||||||
await lineup_service.add_sba_player_to_lineup(
|
await lineup_service.add_sba_player_to_lineup(
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
team_id=away_team_id,
|
team_id=away_team_id,
|
||||||
player_id=player["player_id"],
|
player_id=player["player_id"],
|
||||||
position=player["position"],
|
position=player["position"],
|
||||||
batting_order=player["batting_order"],
|
batting_order=player["batting_order"],
|
||||||
is_starter=True,
|
is_starter=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start the game
|
# Start the game
|
||||||
await game_engine.start_game(game_id)
|
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)}")
|
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)
|
@router.post("/{game_id}/lineups", response_model=SubmitLineupsResponse)
|
||||||
async def submit_lineups(game_id: str, request: SubmitLineupsRequest):
|
async def submit_lineups(game_id: str, request: SubmitLineupsRequest):
|
||||||
"""
|
"""
|
||||||
|
|||||||
79
backend/app/api/routes/schedule.py
Normal file
79
backend/app/api/routes/schedule.py
Normal 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}",
|
||||||
|
)
|
||||||
@ -40,6 +40,29 @@ class StateManager:
|
|||||||
- Recover game states from database on demand
|
- Recover game states from database on demand
|
||||||
|
|
||||||
This class uses dictionaries for O(1) lookups of game state by game_id.
|
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):
|
def __init__(self):
|
||||||
@ -522,8 +545,14 @@ class StateManager:
|
|||||||
f"{runner_count} runners on base, {state.outs} outs"
|
f"{runner_count} runners on base, {state.outs} outs"
|
||||||
)
|
)
|
||||||
else:
|
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")
|
logger.debug("No completed plays found - initializing fresh state")
|
||||||
else:
|
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")
|
logger.debug("No plays found - initializing fresh state")
|
||||||
|
|
||||||
# Count runners on base
|
# Count runners on base
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
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.config import get_settings
|
||||||
from app.core.exceptions import (
|
from app.core.exceptions import (
|
||||||
DatabaseError,
|
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(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(games.router, prefix="/api/games", tags=["games"])
|
app.include_router(games.router, prefix="/api/games", tags=["games"])
|
||||||
app.include_router(teams.router, prefix="/api/teams", tags=["teams"])
|
app.include_router(teams.router, prefix="/api/teams", tags=["teams"])
|
||||||
|
app.include_router(schedule.router, prefix="/api/schedule", tags=["schedule"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -306,6 +306,79 @@ class SbaApiClient:
|
|||||||
logger.info(f"Loaded {len(results)}/{len(player_ids)} players")
|
logger.info(f"Loaded {len(results)}/{len(player_ids)} players")
|
||||||
return results
|
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
|
# Singleton instance
|
||||||
sba_api_client = SbaApiClient()
|
sba_api_client = SbaApiClient()
|
||||||
|
|||||||
87
frontend-sba/components/Schedule/GameCard.vue
Normal file
87
frontend-sba/components/Schedule/GameCard.vue
Normal 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>
|
||||||
69
frontend-sba/components/Schedule/WeekNavigation.vue
Normal file
69
frontend-sba/components/Schedule/WeekNavigation.vue
Normal 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>
|
||||||
191
frontend-sba/composables/useSchedule.ts
Normal file
191
frontend-sba/composables/useSchedule.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,6 +28,17 @@
|
|||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="mb-6 border-b border-gray-200">
|
<div class="mb-6 border-b border-gray-200">
|
||||||
<nav class="flex space-x-8">
|
<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
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'py-4 px-1 border-b-2 font-medium text-sm transition',
|
'py-4 px-1 border-b-2 font-medium text-sm transition',
|
||||||
@ -35,7 +46,7 @@
|
|||||||
? 'border-primary text-primary'
|
? 'border-primary text-primary'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
]"
|
]"
|
||||||
@click="activeTab = 'active'"
|
@click="handleTabChange('active')"
|
||||||
>
|
>
|
||||||
Active Games
|
Active Games
|
||||||
</button>
|
</button>
|
||||||
@ -46,21 +57,21 @@
|
|||||||
? 'border-primary text-primary'
|
? 'border-primary text-primary'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
]"
|
]"
|
||||||
@click="activeTab = 'completed'"
|
@click="handleTabChange('completed')"
|
||||||
>
|
>
|
||||||
Completed
|
Completed
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State (for games list) -->
|
||||||
<div v-if="loading" class="bg-white rounded-lg shadow-md p-12 text-center">
|
<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"/>
|
<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>
|
<p class="text-gray-900 font-semibold">Loading games...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State (for games list) -->
|
||||||
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-lg p-6">
|
<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-800 font-semibold">Failed to load games</p>
|
||||||
<p class="text-red-600 text-sm mt-2">{{ error }}</p>
|
<p class="text-red-600 text-sm mt-2">{{ error }}</p>
|
||||||
<button
|
<button
|
||||||
@ -71,7 +82,110 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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'">
|
<div v-else-if="activeTab === 'active'">
|
||||||
<!-- Games List -->
|
<!-- Games List -->
|
||||||
<div v-if="activeGames.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Completed Games Tab -->
|
||||||
<div v-else-if="activeTab === 'completed'">
|
<div v-else-if="activeTab === 'completed'">
|
||||||
<!-- Completed Games List -->
|
<!-- Completed Games List -->
|
||||||
<div v-if="completedGames.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<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
|
middleware: ['auth'], // Require authentication
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeTab = ref<'active' | 'completed'>('active')
|
const activeTab = ref<'schedule' | 'active' | 'completed'>('schedule')
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -224,6 +339,49 @@ const loading = ref(true)
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const isCreatingQuickGame = ref(false)
|
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
|
// Quick-create a demo game with pre-configured lineups
|
||||||
async function handleQuickCreate() {
|
async function handleQuickCreate() {
|
||||||
try {
|
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
|
// Fetch games - uses internal URL for SSR, public URL for client
|
||||||
const apiUrl = useApiUrl()
|
const apiUrl = useApiUrl()
|
||||||
const { data: games, pending, error: fetchError, refresh } = await useAsyncData(
|
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') || []
|
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 () => {
|
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
|
// This handles the case where client-side navigation doesn't trigger SSR
|
||||||
if ((!games.value || games.value.length === 0) && authStore.isAuthenticated) {
|
if ((!games.value || games.value.length === 0) && authStore.isAuthenticated) {
|
||||||
console.log('[Games Page] No games on mount, re-fetching...')
|
console.log('[Games Page] No games on mount, re-fetching...')
|
||||||
|
|||||||
@ -110,3 +110,11 @@ export type {
|
|||||||
RefreshTokenRequest,
|
RefreshTokenRequest,
|
||||||
RefreshTokenResponse,
|
RefreshTokenResponse,
|
||||||
} from './api'
|
} from './api'
|
||||||
|
|
||||||
|
// Schedule types
|
||||||
|
export type {
|
||||||
|
SbaCurrent,
|
||||||
|
SbaScheduledGame,
|
||||||
|
GetCurrentResponse,
|
||||||
|
GetScheduleGamesResponse,
|
||||||
|
} from './schedule'
|
||||||
|
|||||||
74
frontend-sba/types/schedule.ts
Normal file
74
frontend-sba/types/schedule.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user