strat-gameplay-webapp/.claude/implementation/STAT_SYSTEM_MATERIALIZED_VIEWS.md
Cal Corum b5677d0c55 CLAUDE: Phase 3.5 Planning - Code Polish & Statistics System
Completed comprehensive planning for Phase 3.5 with focus on production
readiness through materialized views approach for statistics.

Planning Documents Created:
- STAT_SYSTEM_ANALYSIS.md: Analysis of existing major-domo schema
  * Reviewed legacy BattingStat/PitchingStat tables (deprecated)
  * Analyzed existing /plays/batting and /plays/pitching endpoints
  * Evaluated 3 approaches (legacy port, modern, hybrid)

- STAT_SYSTEM_MATERIALIZED_VIEWS.md: Recommended approach
  * PostgreSQL materialized views (following major-domo pattern)
  * Add stat fields to plays table (18 new columns)
  * 3 views: batting_game_stats, pitching_game_stats, game_stats
  * PlayStatCalculator service (~150 lines vs 400+ for StatTracker)
  * 80% less code, single source of truth, always consistent

- phase-3.5-polish-stats.md: Complete implementation plan
  * Task 1: Game Statistics System (materialized views)
  * Task 2: Authorization Framework (WebSocket security)
  * Task 3: Uncapped Hit Decision Trees
  * Task 4: Code Cleanup (remove TODOs, integrate features)
  * Task 5: Integration Test Infrastructure
  * Estimated: 16-24 hours (2-3 days)

NEXT_SESSION.md Updates:
- Phase 3.5 ready to begin (0% → implementation phase)
- Complete task breakdown with acceptance criteria
- Materialized view approach detailed
- Commit strategy for 3 separate commits
- Files to review before starting

Implementation Status Updates:
- Phase 3: 100% Complete (688 tests passing)
- Phase 3F: Substitution system fully tested
- Phase 3.5: Planning complete, ready for implementation
- Updated component status table with Phase 3 completion

Key Decisions:
- Use materialized views (not separate stat tables)
- Add stat fields to plays table
- Refresh views after game completion + on-demand
- Use legacy field names (pa, ab, run, hit) for compatibility
- Skip experimental fields (bphr, xba, etc.) for MVP

Benefits of Materialized Views:
- 80% less code (~400 lines → ~150 lines)
- Single source of truth (plays table)
- Always consistent (stats derived, not tracked)
- Follows existing major-domo pattern
- PostgreSQL optimized (indexed, cached)

Next Steps:
1. Implement PlayStatCalculator (map PlayOutcome → stats)
2. Add stat fields to plays table (migration 004)
3. Create materialized views (migration 005)
4. Create BoxScoreService (query views)
5. Refresh logic after game completion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:08:23 -06:00

697 lines
22 KiB
Markdown

# Statistics System: Materialized Views Approach
**Date**: 2025-11-06
**Approach**: PostgreSQL Materialized Views (following legacy API pattern)
---
## Why Materialized Views?
### Legacy API Pattern
The existing major-domo API already does this:
- **`/plays/batting`** - Aggregates plays on-the-fly with SQL SUM/COUNT
- **`/plays/pitching`** - Aggregates plays on-the-fly with SQL SUM/COUNT
- **BattingStat/PitchingStat tables** - Deprecated in favor of play aggregation
### Benefits
1.**Single source of truth**: Plays table is the only data we write
2.**No stat tracking code**: PostgreSQL does the aggregation
3.**Always consistent**: Stats are derived, not stored separately
4.**Fast queries**: Materialized views are indexed and cached
5.**Refresh on demand**: Update views when needed (after games, on schedule)
6.**No sync issues**: Can't have play/stat mismatches
### Drawbacks
- ⚠️ **Refresh required**: Views must be refreshed to show latest data
- ⚠️ **PostgreSQL specific**: Can't easily port to other databases
- ⚠️ **Migration complexity**: Need to manage view schema changes
**Verdict**: Benefits far outweigh drawbacks for this use case
---
## Proposed Schema
### Existing Tables (Already Have)
```sql
-- We already have these in the web app
CREATE TABLE games (
id UUID PRIMARY KEY,
league_id VARCHAR(50),
home_team_id INT,
away_team_id INT,
status VARCHAR(20),
current_inning INT,
current_half VARCHAR(3),
home_score INT,
away_score INT,
-- ... other game fields
);
CREATE TABLE lineups (
id SERIAL PRIMARY KEY,
game_id UUID REFERENCES games(id),
card_id INT, -- Player card ID
team_id INT,
position VARCHAR(3),
batting_order INT,
is_active BOOLEAN,
is_starter BOOLEAN,
-- ... other lineup fields
);
CREATE TABLE plays (
id SERIAL PRIMARY KEY,
game_id UUID REFERENCES games(id),
play_num INT,
inning INT,
half VARCHAR(3),
batter_lineup_id INT REFERENCES lineups(id),
pitcher_lineup_id INT REFERENCES lineups(id),
play_result VARCHAR(50), -- PlayOutcome enum value
-- ... other play fields (runners, outs, scores, etc.)
);
```
**Need to add to Plays table** (currently missing):
```sql
ALTER TABLE plays ADD COLUMN IF NOT EXISTS pa INT DEFAULT 0; -- Plate appearance (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS ab INT DEFAULT 0; -- At bat (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS hit INT DEFAULT 0; -- Hit (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS run INT DEFAULT 0; -- Runs scored this play
ALTER TABLE plays ADD COLUMN IF NOT EXISTS rbi INT DEFAULT 0; -- RBIs this play
ALTER TABLE plays ADD COLUMN IF NOT EXISTS double INT DEFAULT 0; -- Double (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS triple INT DEFAULT 0; -- Triple (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS hr INT DEFAULT 0; -- Home run (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS bb INT DEFAULT 0; -- Walk (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS so INT DEFAULT 0; -- Strikeout (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS hbp INT DEFAULT 0; -- Hit by pitch (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS sac INT DEFAULT 0; -- Sacrifice (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS sb INT DEFAULT 0; -- Stolen base (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS cs INT DEFAULT 0; -- Caught stealing (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS gidp INT DEFAULT 0; -- Ground into DP (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS error INT DEFAULT 0; -- Error this play
ALTER TABLE plays ADD COLUMN IF NOT EXISTS wp INT DEFAULT 0; -- Wild pitch (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS pb INT DEFAULT 0; -- Passed ball (0 or 1)
ALTER TABLE plays ADD COLUMN IF NOT EXISTS outs_recorded INT DEFAULT 0; -- Outs recorded this play
```
**Why add stat fields to plays?**
- Each play records the statistical events that occurred
- Makes aggregation queries simple and fast
- Matches legacy StratPlay model exactly
- Can be calculated from PlayOutcome when play is recorded
---
### New Materialized Views
#### 1. Batting Stats by Player-Game
```sql
CREATE MATERIALIZED VIEW batting_game_stats AS
SELECT
l.game_id,
l.id AS lineup_id,
l.card_id AS player_card_id,
l.team_id,
-- Aggregate batting stats
COUNT(*) FILTER (WHERE p.pa = 1) AS pa,
COUNT(*) FILTER (WHERE p.ab = 1) AS ab,
SUM(p.run) AS run,
SUM(p.hit) AS hit,
SUM(p.rbi) AS rbi,
SUM(p.double) AS double,
SUM(p.triple) AS triple,
SUM(p.hr) AS hr,
SUM(p.bb) AS bb,
SUM(p.so) AS so,
SUM(p.hbp) AS hbp,
SUM(p.sac) AS sac,
SUM(p.sb) AS sb,
SUM(p.cs) AS cs,
SUM(p.gidp) AS gidp
FROM lineups l
JOIN plays p ON p.batter_lineup_id = l.id
WHERE l.is_active = TRUE OR l.is_starter = TRUE -- Include all who played
GROUP BY l.game_id, l.id, l.card_id, l.team_id;
-- Indexes for fast lookups
CREATE UNIQUE INDEX idx_batting_game_stats_lineup ON batting_game_stats(lineup_id);
CREATE INDEX idx_batting_game_stats_game ON batting_game_stats(game_id);
CREATE INDEX idx_batting_game_stats_player ON batting_game_stats(player_card_id);
```
**Usage**: `SELECT * FROM batting_game_stats WHERE game_id = ?`
---
#### 2. Pitching Stats by Player-Game
```sql
CREATE MATERIALIZED VIEW pitching_game_stats AS
SELECT
l.game_id,
l.id AS lineup_id,
l.card_id AS player_card_id,
l.team_id,
-- Aggregate pitching stats
COUNT(*) FILTER (WHERE p.pa = 1) AS batters_faced,
SUM(p.hit) AS hit_allowed,
SUM(p.run) AS run_allowed,
-- TODO: Calculate earned runs (need error tracking)
SUM(p.run) AS erun, -- Placeholder: all runs as earned for now
SUM(p.bb) AS bb,
SUM(p.so) AS so,
SUM(p.hbp) AS hbp,
SUM(p.hr) AS hr_allowed,
SUM(p.wp) AS wp,
-- Calculate innings pitched from outs
-- IP = outs / 3.0 (e.g., 16 outs = 5.1 IP)
(SUM(p.outs_recorded)::float / 3.0) AS ip
FROM lineups l
JOIN plays p ON p.pitcher_lineup_id = l.id
WHERE l.position = 'P' AND (l.is_active = TRUE OR l.is_starter = TRUE)
GROUP BY l.game_id, l.id, l.card_id, l.team_id;
-- Indexes
CREATE UNIQUE INDEX idx_pitching_game_stats_lineup ON pitching_game_stats(lineup_id);
CREATE INDEX idx_pitching_game_stats_game ON pitching_game_stats(game_id);
CREATE INDEX idx_pitching_game_stats_player ON pitching_game_stats(player_card_id);
```
**Usage**: `SELECT * FROM pitching_game_stats WHERE game_id = ?`
---
#### 3. Team Game Stats (Summary + Linescore)
For box scores, we need team-level aggregates and linescore (runs by inning):
```sql
CREATE MATERIALIZED VIEW game_stats AS
WITH inning_scores AS (
-- Calculate runs scored per team per inning
SELECT
game_id,
inning,
half,
SUM(run) AS runs_scored
FROM plays
GROUP BY game_id, inning, half
),
linescores AS (
-- Build linescore JSON arrays
SELECT
game_id,
json_agg(runs_scored ORDER BY inning) FILTER (WHERE half = 'top') AS away_linescore,
json_agg(runs_scored ORDER BY inning) FILTER (WHERE half = 'bot') AS home_linescore
FROM inning_scores
GROUP BY game_id
),
batting_totals AS (
-- Team batting totals
SELECT
game_id,
l.team_id,
SUM(p.hit) AS hits,
SUM(p.error) AS errors
FROM plays p
JOIN lineups l ON p.batter_lineup_id = l.id
GROUP BY game_id, l.team_id
)
SELECT
g.id AS game_id,
g.home_score AS home_runs,
g.away_score AS away_runs,
-- Linescore
COALESCE(ls.home_linescore, '[]'::json) AS home_linescore,
COALESCE(ls.away_linescore, '[]'::json) AS away_linescore,
-- Home team totals
MAX(bth.hits) AS home_hits,
MAX(bth.errors) AS home_errors,
-- Away team totals
MAX(bta.hits) AS away_hits,
MAX(bta.errors) AS away_errors
FROM games g
LEFT JOIN linescores ls ON g.id = ls.game_id
LEFT JOIN batting_totals bth ON g.id = bth.game_id AND bth.team_id = g.home_team_id
LEFT JOIN batting_totals bta ON g.id = bta.game_id AND bta.team_id = g.away_team_id
GROUP BY g.id, ls.home_linescore, ls.away_linescore;
-- Index
CREATE UNIQUE INDEX idx_game_stats_game ON game_stats(game_id);
```
**Usage**: `SELECT * FROM game_stats WHERE game_id = ?`
**Linescore Format**: `[0, 1, 0, 3, 0, 0, 2, 1, 0]` (9 innings)
---
## Implementation Strategy
### Phase 3.5 Changes
#### 1. Add Stat Fields to Plays Table (Migration)
**File**: `backend/alembic/versions/004_add_play_stat_fields.py`
```python
"""Add statistical fields to plays table.
Revision ID: 004
Revises: 003
"""
def upgrade():
# Add batting stat fields
op.add_column('plays', sa.Column('pa', sa.Integer(), default=0))
op.add_column('plays', sa.Column('ab', sa.Integer(), default=0))
op.add_column('plays', sa.Column('hit', sa.Integer(), default=0))
op.add_column('plays', sa.Column('run', sa.Integer(), default=0))
op.add_column('plays', sa.Column('rbi', sa.Integer(), default=0))
op.add_column('plays', sa.Column('double', sa.Integer(), default=0))
op.add_column('plays', sa.Column('triple', sa.Integer(), default=0))
op.add_column('plays', sa.Column('hr', sa.Integer(), default=0))
op.add_column('plays', sa.Column('bb', sa.Integer(), default=0))
op.add_column('plays', sa.Column('so', sa.Integer(), default=0))
op.add_column('plays', sa.Column('hbp', sa.Integer(), default=0))
op.add_column('plays', sa.Column('sac', sa.Integer(), default=0))
op.add_column('plays', sa.Column('sb', sa.Integer(), default=0))
op.add_column('plays', sa.Column('cs', sa.Integer(), default=0))
op.add_column('plays', sa.Column('gidp', sa.Integer(), default=0))
op.add_column('plays', sa.Column('error', sa.Integer(), default=0))
op.add_column('plays', sa.Column('wp', sa.Integer(), default=0))
op.add_column('plays', sa.Column('pb', sa.Integer(), default=0))
op.add_column('plays', sa.Column('outs_recorded', sa.Integer(), default=0))
def downgrade():
# Remove all stat columns
op.drop_column('plays', 'pa')
# ... etc
```
#### 2. Update Play Model
**File**: `backend/app/models/db_models.py`
```python
class Play(Base):
__tablename__ = "plays"
# Existing fields
id = Column(Integer, primary_key=True)
game_id = Column(UUID, ForeignKey("games.id"))
# ... etc
# NEW: Statistical fields (added in migration 004)
pa = Column(Integer, default=0)
ab = Column(Integer, default=0)
hit = Column(Integer, default=0)
run = Column(Integer, default=0)
rbi = Column(Integer, default=0)
double = Column(Integer, default=0)
triple = Column(Integer, default=0)
hr = Column(Integer, default=0)
bb = Column(Integer, default=0)
so = Column(Integer, default=0)
hbp = Column(Integer, default=0)
sac = Column(Integer, default=0)
sb = Column(Integer, default=0)
cs = Column(Integer, default=0)
gidp = Column(Integer, default=0)
error = Column(Integer, default=0)
wp = Column(Integer, default=0)
pb = Column(Integer, default=0)
outs_recorded = Column(Integer, default=0)
```
#### 3. Create Stat Calculator
**File**: `backend/app/services/play_stat_calculator.py` (NEW)
```python
"""
Calculate statistical fields from PlayOutcome.
Called when recording a play to populate stat fields.
"""
from app.config.play_outcome import PlayOutcome
from app.models.game_models import PlayResult, GameState
class PlayStatCalculator:
"""
Converts play outcome to statistical fields for database.
"""
@staticmethod
def calculate_stats(
outcome: PlayOutcome,
result: PlayResult,
state_before: GameState,
state_after: GameState
) -> dict:
"""
Calculate all stat fields for a play.
Returns:
dict with stat fields (pa, ab, hit, run, etc.)
"""
stats = {
'pa': 0, 'ab': 0, 'hit': 0, 'run': 0, 'rbi': 0,
'double': 0, 'triple': 0, 'hr': 0, 'bb': 0, 'so': 0,
'hbp': 0, 'sac': 0, 'sb': 0, 'cs': 0, 'gidp': 0,
'error': 0, 'wp': 0, 'pb': 0, 'outs_recorded': 0
}
# Plate appearance (almost always 1, except...)
if outcome not in [PlayOutcome.STOLEN_BASE_SUCCESS,
PlayOutcome.CAUGHT_STEALING,
PlayOutcome.WILD_PITCH,
PlayOutcome.PASSED_BALL]:
stats['pa'] = 1
# At bat (PA minus walks, HBP, sac)
if stats['pa'] == 1 and outcome not in [
PlayOutcome.WALK, PlayOutcome.HBP, PlayOutcome.SACRIFICE
]:
stats['ab'] = 1
# Hits
if outcome in PlayOutcome.get_hit_outcomes():
stats['hit'] = 1
if outcome in PlayOutcome.get_double_outcomes():
stats['double'] = 1
elif outcome in PlayOutcome.get_triple_outcomes():
stats['triple'] = 1
elif outcome in PlayOutcome.get_homerun_outcomes():
stats['hr'] = 1
# Other outcomes
if outcome == PlayOutcome.WALK:
stats['bb'] = 1
elif outcome == PlayOutcome.STRIKEOUT:
stats['so'] = 1
elif outcome == PlayOutcome.HBP:
stats['hbp'] = 1
elif outcome == PlayOutcome.SACRIFICE:
stats['sac'] = 1
elif outcome == PlayOutcome.STOLEN_BASE_SUCCESS:
stats['sb'] = 1
elif outcome == PlayOutcome.CAUGHT_STEALING:
stats['cs'] = 1
elif outcome in [PlayOutcome.GROUNDBALL_GDP]:
stats['gidp'] = 1
elif outcome == PlayOutcome.WILD_PITCH:
stats['wp'] = 1
elif outcome == PlayOutcome.PASSED_BALL:
stats['pb'] = 1
# Calculate runs and RBIs from state change
stats['run'] = state_after.home_score - state_before.home_score
if state_after.away_score > state_before.away_score:
stats['run'] = state_after.away_score - state_before.away_score
# RBI logic (runs scored minus errors)
if not result.error_occurred:
stats['rbi'] = stats['run']
# Outs recorded
stats['outs_recorded'] = state_after.outs - state_before.outs
return stats
```
#### 4. Update GameEngine to Calculate Stats
**File**: `backend/app/core/game_engine.py`
```python
from app.services.play_stat_calculator import PlayStatCalculator
class GameEngine:
async def record_play(
self,
game_id: UUID,
outcome: PlayOutcome,
...
) -> PlayResult:
"""Record play and calculate stats."""
state_before = self.state_manager.get_state(game_id)
# Resolve play (existing logic)
result = self._resolve_play(...)
state_after = self.state_manager.get_state(game_id)
# NEW: Calculate stat fields
stats = PlayStatCalculator.calculate_stats(
outcome, result, state_before, state_after
)
# Save play to database with stats
await self.db_ops.create_play(
game_id=game_id,
play_num=state_after.play_count,
inning=state_after.current_inning,
half=state_after.current_half,
batter_lineup_id=state_before.current_batter.lineup_id,
pitcher_lineup_id=state_before.current_pitcher.lineup_id,
play_result=outcome.value,
# NEW: Add stat fields
**stats
)
return result
```
#### 5. Create Materialized Views (Migration)
**File**: `backend/alembic/versions/005_create_stat_views.py`
```python
"""Create materialized views for statistics.
Revision ID: 005
Revises: 004
"""
def upgrade():
# Create batting_game_stats view
op.execute("""
CREATE MATERIALIZED VIEW batting_game_stats AS
SELECT
l.game_id,
l.id AS lineup_id,
l.card_id AS player_card_id,
l.team_id,
COUNT(*) FILTER (WHERE p.pa = 1) AS pa,
COUNT(*) FILTER (WHERE p.ab = 1) AS ab,
SUM(p.run) AS run,
SUM(p.hit) AS hit,
SUM(p.rbi) AS rbi,
SUM(p.double) AS double,
SUM(p.triple) AS triple,
SUM(p.hr) AS hr,
SUM(p.bb) AS bb,
SUM(p.so) AS so,
SUM(p.hbp) AS hbp,
SUM(p.sac) AS sac,
SUM(p.sb) AS sb,
SUM(p.cs) AS cs,
SUM(p.gidp) AS gidp
FROM lineups l
JOIN plays p ON p.batter_lineup_id = l.id
GROUP BY l.game_id, l.id, l.card_id, l.team_id
""")
op.execute("""
CREATE UNIQUE INDEX idx_batting_game_stats_lineup
ON batting_game_stats(lineup_id)
""")
# Create pitching_game_stats view
op.execute("""
CREATE MATERIALIZED VIEW pitching_game_stats AS
... (full SQL from above)
""")
# Create game_stats view
op.execute("""
CREATE MATERIALIZED VIEW game_stats AS
... (full SQL from above)
""")
def downgrade():
op.execute("DROP MATERIALIZED VIEW IF EXISTS batting_game_stats")
op.execute("DROP MATERIALIZED VIEW IF EXISTS pitching_game_stats")
op.execute("DROP MATERIALIZED VIEW IF EXISTS game_stats")
```
#### 6. Create View Refresh Service
**File**: `backend/app/services/stat_view_refresher.py` (NEW)
```python
"""
Service to refresh materialized views.
"""
from sqlalchemy import text
from app.database.database import async_session
class StatViewRefresher:
"""Manages refreshing of statistical materialized views."""
@staticmethod
async def refresh_game_stats(game_id: UUID) -> None:
"""
Refresh stats for a specific game (partial refresh).
Note: PostgreSQL doesn't support partial refresh of materialized views.
We'd need to implement this with regular views or partitioning.
For now, refresh all views after game completion.
"""
await StatViewRefresher.refresh_all()
@staticmethod
async def refresh_all() -> None:
"""Refresh all statistical views."""
async with async_session() as session:
await session.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY batting_game_stats"))
await session.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY pitching_game_stats"))
await session.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY game_stats"))
await session.commit()
```
#### 7. Box Score Retrieval
**File**: `backend/app/services/box_score_service.py` (NEW)
```python
"""
Service to retrieve formatted box scores.
"""
from sqlalchemy import text
from app.database.database import async_session
class BoxScoreService:
"""Retrieves box score data from materialized views."""
@staticmethod
async def get_box_score(game_id: UUID) -> dict:
"""
Get complete box score for a game.
Returns dict with:
- game_stats: Team totals and linescore
- batting_stats: List of batting lines
- pitching_stats: List of pitching lines
"""
async with async_session() as session:
# Get game stats
game_query = text("""
SELECT * FROM game_stats WHERE game_id = :game_id
""")
game_result = await session.execute(game_query, {"game_id": str(game_id)})
game_stats = game_result.fetchone()._asdict() if game_result else {}
# Get batting stats
batting_query = text("""
SELECT * FROM batting_game_stats WHERE game_id = :game_id
ORDER BY lineup_id
""")
batting_result = await session.execute(batting_query, {"game_id": str(game_id)})
batting_stats = [row._asdict() for row in batting_result]
# Get pitching stats
pitching_query = text("""
SELECT * FROM pitching_game_stats WHERE game_id = :game_id
ORDER BY lineup_id
""")
pitching_result = await session.execute(pitching_query, {"game_id": str(game_id)})
pitching_stats = [row._asdict() for row in pitching_result]
return {
'game_stats': game_stats,
'batting_stats': batting_stats,
'pitching_stats': pitching_stats
}
```
---
## Refresh Strategy
### When to Refresh Views?
**Option 1: After Each Play** (Real-time)
- Pros: Always up-to-date
- Cons: Performance impact, too frequent
**Option 2: After Game Completion** (Recommended for MVP)
- Pros: Good balance of freshness and performance
- Cons: Stats not visible until game ends
**Option 3: On Demand** (Terminal client)
- Pros: User controls refresh
- Cons: Stale data between refreshes
**Option 4: Scheduled** (Every 5 minutes)
- Pros: Predictable, low impact
- Cons: 5-minute staleness
**Recommended**: Option 2 (game completion) + Option 3 (on-demand for testing)
---
## Benefits of This Approach
1. **No Stat Tracker Needed**: Eliminated ~400 lines of stat tracking code
2. **Single Source of Truth**: Plays table is the only data we write
3. **Consistency Guaranteed**: Stats are always derived from plays
4. **Simple Logic**: PlayStatCalculator is straightforward field mapping
5. **SQL Optimized**: PostgreSQL handles aggregation efficiently
6. **Flexible Queries**: Can create any stat aggregation we want with new views
7. **Audit Trail**: Play-by-play history is complete record
---
## Migration Path
1. Add stat fields to plays table (migration 004)
2. Update Play model
3. Create PlayStatCalculator
4. Modify GameEngine to populate stat fields when recording plays
5. Create materialized views (migration 005)
6. Create BoxScoreService to query views
7. Add refresh logic (after game completion)
8. Update terminal client to show box scores
**Estimated Time**: 4-5 hours (much less than original 6-8 hours!)
---
## Questions for Approval
1. ✅ Approve materialized view approach (vs separate stat tables)?
2. ✅ Add stat fields to plays table?
3. ✅ Refresh strategy: after game completion + on-demand?
4. ✅ Use legacy field names (pa, ab, run, hit) for compatibility?
5. ✅ Skip experimental fields (bphr, xba, etc.) for Phase 3.5?
Once approved, I'll update `phase-3.5-polish-stats.md` with this approach.