strat-gameplay-webapp/backend/app/services/box_score_service.py
Cal Corum eab61ad966 CLAUDE: Phases 3.5, F1-F5 Complete - Statistics & Frontend Components
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>
2025-11-14 09:52:30 -06:00

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