This commit captures work from multiple sessions building the statistics system and frontend component library. Backend - Phase 3.5: Statistics System - Box score statistics with materialized views - Play stat calculator for real-time updates - Stat view refresher service - Alembic migration for materialized views - Test coverage: 41 new tests (all passing) Frontend - Phase F1: Foundation - Composables: useGameState, useGameActions, useWebSocket - Type definitions and interfaces - Store setup with Pinia Frontend - Phase F2: Game Display - ScoreBoard, GameBoard, CurrentSituation, PlayByPlay components - Demo page at /demo Frontend - Phase F3: Decision Inputs - DefensiveSetup, OffensiveApproach, StolenBaseInputs components - DecisionPanel orchestration - Demo page at /demo-decisions - Test coverage: 213 tests passing Frontend - Phase F4: Dice & Manual Outcome - DiceRoller component - ManualOutcomeEntry with validation - PlayResult display - GameplayPanel orchestration - Demo page at /demo-gameplay - Test coverage: 119 tests passing Frontend - Phase F5: Substitutions - PinchHitterSelector, DefensiveReplacementSelector, PitchingChangeSelector - SubstitutionPanel with tab navigation - Demo page at /demo-substitutions - Test coverage: 114 tests passing Documentation: - PHASE_3_5_HANDOFF.md - Statistics system handoff - PHASE_F2_COMPLETE.md - Game display completion - Frontend phase planning docs - NEXT_SESSION.md updated for Phase F6 Configuration: - Package updates (Nuxt 4 fixes) - Tailwind config enhancements - Game store updates Test Status: - Backend: 731/731 passing (100%) - Frontend: 446/446 passing (100%) - Total: 1,177 tests passing Next Phase: F6 - Integration (wire all components into game page) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
233 lines
6.7 KiB
Python
233 lines
6.7 KiB
Python
"""
|
|
BoxScoreService - Query materialized views for formatted box scores.
|
|
|
|
Provides game statistics, batting stats, and pitching stats by querying
|
|
PostgreSQL materialized views created in migration 004.
|
|
|
|
Author: Claude
|
|
Date: 2025-11-07
|
|
"""
|
|
import logging
|
|
from typing import Optional, Dict, List
|
|
from uuid import UUID
|
|
from sqlalchemy import text
|
|
from app.database.session import AsyncSessionLocal
|
|
|
|
logger = logging.getLogger(f'{__name__}.BoxScoreService')
|
|
|
|
|
|
class BoxScoreService:
|
|
"""
|
|
Service for retrieving box score data from materialized views.
|
|
|
|
Queries three materialized views:
|
|
- game_stats: Team totals and linescore
|
|
- batting_game_stats: Player batting statistics
|
|
- pitching_game_stats: Player pitching statistics
|
|
"""
|
|
|
|
async def get_box_score(self, game_id: UUID) -> Optional[Dict]:
|
|
"""
|
|
Get complete box score for a game.
|
|
|
|
Args:
|
|
game_id: Game identifier
|
|
|
|
Returns:
|
|
Dictionary with game_stats, batting_stats, and pitching_stats
|
|
Returns None if game not found
|
|
|
|
Raises:
|
|
Exception: If database query fails
|
|
"""
|
|
async with AsyncSessionLocal() as session:
|
|
try:
|
|
# Query all three views
|
|
game_stats = await self._get_game_stats(session, game_id)
|
|
|
|
if not game_stats:
|
|
logger.warning(f"No game stats found for game {game_id}")
|
|
return None
|
|
|
|
batting_stats = await self._get_batting_stats(session, game_id)
|
|
pitching_stats = await self._get_pitching_stats(session, game_id)
|
|
|
|
logger.info(f"Retrieved box score for game {game_id}: "
|
|
f"{len(batting_stats)} batters, {len(pitching_stats)} pitchers")
|
|
|
|
return {
|
|
'game_stats': game_stats,
|
|
'batting_stats': batting_stats,
|
|
'pitching_stats': pitching_stats
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get box score for game {game_id}: {e}", exc_info=True)
|
|
raise
|
|
|
|
async def _get_game_stats(self, session, game_id: UUID) -> Optional[Dict]:
|
|
"""
|
|
Query game_stats materialized view for team totals and linescore.
|
|
|
|
Args:
|
|
session: Active database session
|
|
game_id: Game identifier
|
|
|
|
Returns:
|
|
Dictionary with game statistics or None if not found
|
|
"""
|
|
query = text("""
|
|
SELECT
|
|
game_id,
|
|
home_runs,
|
|
away_runs,
|
|
home_hits,
|
|
away_hits,
|
|
home_errors,
|
|
away_errors,
|
|
home_linescore,
|
|
away_linescore
|
|
FROM game_stats
|
|
WHERE game_id = :game_id
|
|
""")
|
|
|
|
result = await session.execute(query, {'game_id': str(game_id)})
|
|
row = result.fetchone()
|
|
|
|
if not row:
|
|
return None
|
|
|
|
return {
|
|
'game_id': row[0],
|
|
'home_runs': row[1] or 0,
|
|
'away_runs': row[2] or 0,
|
|
'home_hits': row[3] or 0,
|
|
'away_hits': row[4] or 0,
|
|
'home_errors': row[5] or 0,
|
|
'away_errors': row[6] or 0,
|
|
'home_linescore': row[7] or [],
|
|
'away_linescore': row[8] or []
|
|
}
|
|
|
|
async def _get_batting_stats(self, session, game_id: UUID) -> List[Dict]:
|
|
"""
|
|
Query batting_game_stats materialized view for player batting lines.
|
|
|
|
Args:
|
|
session: Active database session
|
|
game_id: Game identifier
|
|
|
|
Returns:
|
|
List of batting statistics dictionaries
|
|
"""
|
|
query = text("""
|
|
SELECT
|
|
lineup_id,
|
|
game_id,
|
|
player_card_id,
|
|
pa,
|
|
ab,
|
|
run,
|
|
hit,
|
|
rbi,
|
|
double,
|
|
triple,
|
|
hr,
|
|
bb,
|
|
so,
|
|
hbp,
|
|
sac,
|
|
sb,
|
|
cs,
|
|
gidp
|
|
FROM batting_game_stats
|
|
WHERE game_id = :game_id
|
|
ORDER BY lineup_id
|
|
""")
|
|
|
|
result = await session.execute(query, {'game_id': str(game_id)})
|
|
rows = result.fetchall()
|
|
|
|
stats = []
|
|
for row in rows:
|
|
stats.append({
|
|
'lineup_id': row[0],
|
|
'game_id': row[1],
|
|
'player_card_id': row[2],
|
|
'pa': row[3] or 0,
|
|
'ab': row[4] or 0,
|
|
'run': row[5] or 0,
|
|
'hit': row[6] or 0,
|
|
'rbi': row[7] or 0,
|
|
'double': row[8] or 0,
|
|
'triple': row[9] or 0,
|
|
'hr': row[10] or 0,
|
|
'bb': row[11] or 0,
|
|
'so': row[12] or 0,
|
|
'hbp': row[13] or 0,
|
|
'sac': row[14] or 0,
|
|
'sb': row[15] or 0,
|
|
'cs': row[16] or 0,
|
|
'gidp': row[17] or 0
|
|
})
|
|
|
|
return stats
|
|
|
|
async def _get_pitching_stats(self, session, game_id: UUID) -> List[Dict]:
|
|
"""
|
|
Query pitching_game_stats materialized view for player pitching lines.
|
|
|
|
Args:
|
|
session: Active database session
|
|
game_id: Game identifier
|
|
|
|
Returns:
|
|
List of pitching statistics dictionaries
|
|
"""
|
|
query = text("""
|
|
SELECT
|
|
lineup_id,
|
|
game_id,
|
|
player_card_id,
|
|
batters_faced,
|
|
hit_allowed,
|
|
run_allowed,
|
|
erun,
|
|
bb,
|
|
so,
|
|
hbp,
|
|
hr_allowed,
|
|
wp,
|
|
ip
|
|
FROM pitching_game_stats
|
|
WHERE game_id = :game_id
|
|
ORDER BY lineup_id
|
|
""")
|
|
|
|
result = await session.execute(query, {'game_id': str(game_id)})
|
|
rows = result.fetchall()
|
|
|
|
stats = []
|
|
for row in rows:
|
|
stats.append({
|
|
'lineup_id': row[0],
|
|
'game_id': row[1],
|
|
'player_card_id': row[2],
|
|
'batters_faced': row[3] or 0,
|
|
'hit_allowed': row[4] or 0,
|
|
'run_allowed': row[5] or 0,
|
|
'erun': row[6] or 0,
|
|
'bb': row[7] or 0,
|
|
'so': row[8] or 0,
|
|
'hbp': row[9] or 0,
|
|
'hr_allowed': row[10] or 0,
|
|
'wp': row[11] or 0,
|
|
'ip': float(row[12]) if row[12] is not None else 0.0
|
|
})
|
|
|
|
return stats
|
|
|
|
|
|
# Singleton instance
|
|
box_score_service = BoxScoreService()
|