From be31e2ccb4fb1a1397122fb445651427884b6470 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 23 Jan 2026 15:23:38 -0600 Subject: [PATCH] CLAUDE: Complete in-game UI overhaul with player cards and outcome wizard Features: - PlayerCardModal: Tap any player to view full playing card image - OutcomeWizard: Progressive 3-step outcome selection (On Base/Out/X-Check) - GameBoard: Expandable view showing all 9 fielder positions - Post-roll card display: Shows batter/pitcher card based on d6 roll - CurrentSituation: Tappable player cards with modal integration Bug fixes: - Fix batter not advancing after play (state_manager recovery logic) - Add dark mode support for buttons and panels (partial - iOS issue noted) New files: - PlayerCardModal.vue, OutcomeWizard.vue, BottomSheet.vue - outcomeFlow.ts constants for outcome category mapping - TEST_PLAN_UI_OVERHAUL.md with 23/24 tests passing Co-Authored-By: Claude Opus 4.5 --- backend/app/core/state_manager.py | 11 + backend/app/database/operations.py | 41 ++ backend/app/services/lineup_service.py | 63 ++- .../integration/database/test_operations.py | 92 ++++ frontend-sba/.claude/TEST_PLAN_UI_OVERHAUL.md | 247 +++++++++ .../components/Game/CurrentSituation.vue | 87 ++- frontend-sba/components/Game/GameBoard.vue | 301 ++++++++++- frontend-sba/components/Game/GamePlay.vue | 38 ++ .../components/Gameplay/DiceRoller.vue | 12 + .../components/Gameplay/GameplayPanel.vue | 126 ++++- .../components/Gameplay/OutcomeWizard.vue | 504 ++++++++++++++++++ .../components/Player/PlayerCardModal.vue | 351 ++++++++++++ frontend-sba/components/UI/ActionButton.vue | 32 ++ frontend-sba/components/UI/BottomSheet.vue | 311 +++++++++++ frontend-sba/constants/outcomeFlow.ts | 220 ++++++++ .../components/Gameplay/GameplayPanel.spec.ts | 10 +- 16 files changed, 2384 insertions(+), 62 deletions(-) create mode 100644 frontend-sba/.claude/TEST_PLAN_UI_OVERHAUL.md create mode 100644 frontend-sba/components/Gameplay/OutcomeWizard.vue create mode 100644 frontend-sba/components/Player/PlayerCardModal.vue create mode 100644 frontend-sba/components/UI/BottomSheet.vue create mode 100644 frontend-sba/constants/outcomeFlow.ts diff --git a/backend/app/core/state_manager.py b/backend/app/core/state_manager.py index 2a2de83..700f08e 100644 --- a/backend/app/core/state_manager.py +++ b/backend/app/core/state_manager.py @@ -560,6 +560,17 @@ class StateManager: f"Recovery: ✓ Set current_batter to idx={next_batter_idx}, " f"card_id={state.current_batter.card_id}, batting_order={state.current_batter.batting_order}" ) + + # CRITICAL: Advance the batter index so _prepare_next_play works correctly + # _prepare_next_play reads the index, sets the batter, then advances. + # Since we've already set the current batter, we need to advance the index + # so the NEXT call to _prepare_next_play will set the correct next batter. + if batting_team_id == away_team_id: + state.away_team_batter_idx = (next_batter_idx + 1) % 9 + logger.info(f"Recovery: Advanced away_team_batter_idx to {state.away_team_batter_idx}") + else: + state.home_team_batter_idx = (next_batter_idx + 1) % 9 + logger.info(f"Recovery: Advanced home_team_batter_idx to {state.home_team_batter_idx}") else: logger.warning( f"Recovery: ✗ Batter index {next_batter_idx} out of range for batting order " diff --git a/backend/app/database/operations.py b/backend/app/database/operations.py index f995718..d357370 100644 --- a/backend/app/database/operations.py +++ b/backend/app/database/operations.py @@ -846,6 +846,47 @@ class DatabaseOperations: for link in bench_links ] + async def get_roster_player_data( + self, game_id: UUID, team_id: int + ) -> dict[int, dict]: + """ + Get cached player data from RosterLink for all players on a team. + + Returns a mapping of player_id -> player_data dict for quick lookup. + Used to avoid SBA API calls when loading lineups from database. + + Args: + game_id: Game identifier + team_id: Team identifier + + Returns: + Dict mapping player_id to player_data (name, image, headshot). + Empty dict if no roster data found. + """ + async with self._get_session() as session: + result = await session.execute( + select(RosterLink.player_id, RosterLink.player_data) + .where( + RosterLink.game_id == game_id, + RosterLink.team_id == team_id, + RosterLink.player_id.is_not(None), + RosterLink.player_data.is_not(None), + ) + ) + rows = result.all() + + player_data_map = { + row.player_id: row.player_data + for row in rows + if row.player_id and row.player_data + } + + logger.debug( + f"Retrieved cached player data for {len(player_data_map)} players " + f"from roster for team {team_id} in game {game_id}" + ) + return player_data_map + async def remove_roster_entry(self, roster_id: int) -> None: """ Remove a roster entry by ID. diff --git a/backend/app/services/lineup_service.py b/backend/app/services/lineup_service.py index 3d5f03c..428a83d 100644 --- a/backend/app/services/lineup_service.py +++ b/backend/app/services/lineup_service.py @@ -122,8 +122,9 @@ class LineupService: Load existing team lineup from database with player data. 1. Fetches full lineup (active + bench) from database - 2. Fetches player data from SBA API (for SBA league) - 3. Returns TeamLineupState with player info populated + 2. Tries to get player data from RosterLink cache first + 3. Falls back to SBA API if cache misses (for SBA league) + 4. Returns TeamLineupState with player info populated Args: game_id: Game identifier @@ -138,19 +139,42 @@ class LineupService: if not lineup_entries: return None - # Step 2: Fetch player data for SBA league - player_data = {} + # Step 2: Get player data - try RosterLink cache first, then API fallback + player_data_cache: dict[int, dict] = {} + api_player_data: dict = {} + if league_id == "sba": player_ids = [p.player_id for p in lineup_entries if p.player_id] # type: ignore[misc] if player_ids: - try: - player_data = await sba_api_client.get_players_batch(player_ids) + # Try RosterLink cache first + player_data_cache = await self.db_ops.get_roster_player_data( + game_id, team_id + ) + cached_count = len(player_data_cache) + + # Find players missing from cache + missing_ids = [ + pid for pid in player_ids if pid not in player_data_cache + ] + + if missing_ids: + # Fall back to SBA API for missing players + try: + api_player_data = await sba_api_client.get_players_batch( + missing_ids + ) + logger.info( + f"Loaded {cached_count} players from cache, " + f"{len(api_player_data)} from API for team {team_id}" + ) + except Exception as e: + logger.warning( + f"Failed to fetch player data from API for team {team_id}: {e}" + ) + else: logger.info( - f"Loaded {len(player_data)}/{len(player_ids)} players for team {team_id}" - ) - except Exception as e: - logger.warning( - f"Failed to fetch player data for team {team_id}: {e}" + f"Loaded all {cached_count} players from RosterLink cache " + f"for team {team_id} (no API call needed)" ) # Step 3: Build TeamLineupState with player data @@ -160,11 +184,18 @@ class LineupService: player_image = None player_headshot = None - if league_id == "sba" and p.player_id and player_data.get(p.player_id): # type: ignore[arg-type] - player = player_data.get(p.player_id) # type: ignore[arg-type] - player_name = player.name - player_image = player.get_image_url() - player_headshot = player.headshot + if league_id == "sba" and p.player_id: # type: ignore[arg-type] + # Check cache first, then API data + if p.player_id in player_data_cache: # type: ignore[arg-type] + cached = player_data_cache[p.player_id] # type: ignore[arg-type] + player_name = cached.get("name") + player_image = cached.get("image") + player_headshot = cached.get("headshot") + elif p.player_id in api_player_data: # type: ignore[arg-type] + player = api_player_data[p.player_id] # type: ignore[arg-type] + player_name = player.name + player_image = player.get_image_url() + player_headshot = player.headshot players.append( LineupPlayerState( diff --git a/backend/tests/integration/database/test_operations.py b/backend/tests/integration/database/test_operations.py index 77eedf5..922cfae 100644 --- a/backend/tests/integration/database/test_operations.py +++ b/backend/tests/integration/database/test_operations.py @@ -871,6 +871,98 @@ class TestDatabaseOperationsRoster: assert len(bench) == 0 + async def test_get_roster_player_data(self, db_ops, db_session): + """ + Test get_roster_player_data returns cached player data from RosterLink. + + This verifies the optimization where lineup loading can use cached + player data from RosterLink instead of making SBA API calls. + """ + game_id = uuid4() + team_id = 10 + + # Create game + await db_ops.create_game( + game_id=game_id, + league_id="sba", + home_team_id=team_id, + away_team_id=20, + game_mode="friendly", + visibility="public" + ) + + # Add players to RosterLink with player_data + await db_ops.add_sba_roster_player( + game_id=game_id, + player_id=101, + team_id=team_id, + player_positions=["SP"], + player_data={"name": "John Pitcher", "image": "http://img/101.png", "headshot": "http://head/101.png"} + ) + await db_ops.add_sba_roster_player( + game_id=game_id, + player_id=102, + team_id=team_id, + player_positions=["CF"], + player_data={"name": "Jane Outfielder", "image": "http://img/102.png", "headshot": ""} + ) + # Player without player_data (should be excluded) + await db_ops.add_sba_roster_player( + game_id=game_id, + player_id=103, + team_id=team_id, + player_positions=["1B"], + player_data=None + ) + await db_session.flush() + + # Get roster player data + player_data = await db_ops.get_roster_player_data(game_id, team_id) + + # Should have 2 players (103 excluded because player_data is None) + assert len(player_data) == 2 + assert 101 in player_data + assert 102 in player_data + assert 103 not in player_data + + # Verify data structure + assert player_data[101]["name"] == "John Pitcher" + assert player_data[101]["image"] == "http://img/101.png" + assert player_data[101]["headshot"] == "http://head/101.png" + assert player_data[102]["name"] == "Jane Outfielder" + + async def test_get_roster_player_data_empty(self, db_ops, db_session): + """ + Test get_roster_player_data returns empty dict when no cached data exists. + """ + game_id = uuid4() + team_id = 10 + + # Create game + await db_ops.create_game( + game_id=game_id, + league_id="sba", + home_team_id=team_id, + away_team_id=20, + game_mode="friendly", + visibility="public" + ) + + # Add player WITHOUT player_data + await db_ops.add_sba_roster_player( + game_id=game_id, + player_id=101, + team_id=team_id, + player_positions=["CF"], + player_data=None + ) + await db_session.flush() + + # Get roster player data (should be empty) + player_data = await db_ops.get_roster_player_data(game_id, team_id) + + assert len(player_data) == 0 + class TestDatabaseOperationsRollback: """Tests for database rollback operations (delete_plays_after, etc.)""" diff --git a/frontend-sba/.claude/TEST_PLAN_UI_OVERHAUL.md b/frontend-sba/.claude/TEST_PLAN_UI_OVERHAUL.md new file mode 100644 index 0000000..b0210fc --- /dev/null +++ b/frontend-sba/.claude/TEST_PLAN_UI_OVERHAUL.md @@ -0,0 +1,247 @@ +# UI Overhaul Test Plan + +## Overview + +Testing the in-game UI improvements from feature/game-page-polish branch. + +## Prerequisites + +- Production build running (`./start.sh prod`) +- Access to https://gameplay-demo.manticorum.com +- Mobile device or browser dev tools for mobile testing +- An active game with both lineups submitted + +--- + +## Test 1: CurrentSituation Player Cards + +### 1.1 Desktop - Pitcher Card + +- [x] Navigate to active game +- [x] Locate pitcher card in CurrentSituation component +- [x] **Click** pitcher card +- [x] **Expected**: PlayerCardModal opens showing pitcher's full playing card image +- [x] **Expected**: Modal shows player name, position +- [x] **Click** outside modal or X button +- [x] **Expected**: Modal closes + +### 1.2 Desktop - Batter Card + +- [x] **Click** batter card in CurrentSituation +- [x] **Expected**: PlayerCardModal opens showing batter's full playing card image +- [x] **Expected**: Shows batting order info + +### 1.3 Mobile - Pitcher Card + +- [x] Switch to mobile view (or use actual device) +- [x] **Tap** pitcher card +- [x] **Expected**: Modal slides up from bottom +- [x] **Swipe down** on modal +- [x] **Expected**: Modal closes + +### 1.4 Mobile - Batter Card + +- [x] **Tap** batter card +- [x] **Expected**: Modal slides up showing batter's card + +--- + +## Test 2: GameBoard Interactivity + +### 2.1 Expand/Collapse Field View + +- [x] Locate expand button (bottom-right of diamond) +- [x] **Click** expand button +- [x] **Expected**: All 9 fielder positions appear (C, 1B, 2B, SS, 3B, LF, CF, RF + P already visible) +- [x] **Click** expand button again (now shows X) +- [x] **Expected**: Field collapses back to simplified view + +### 2.2 Tappable Pitcher on Diamond + +- [x] **Click** pitcher indicator on mound +- [x] **Expected**: PlayerCardModal opens with pitcher's card + +### 2.3 Tappable Batter on Diamond + +- [x] **Click** batter indicator at home plate +- [x] **Expected**: PlayerCardModal opens with batter's card + +--- + +## Test 3: Post-Roll Card Display + +### 3.1 Batter Card Display (d6 = 1-3) + +- [x] Start a play (decisions submitted) +- [x] **Click** "Roll Dice" button +- [x] If d6_one is 1, 2, or 3: + - [x] **Expected**: Batter's card image appears inline + - [x] **Expected**: Shows "BATTER CARD" label in red + - [x] **Expected**: Full-width card image for readability + +### 3.2 Pitcher Card Display (d6 = 4-6) + +- [x] Roll dice again (or wait for next play) +- [x] If d6_one is 4, 5, or 6: + - [x] **Expected**: Pitcher's card image appears inline + - [x] **Expected**: Shows "PITCHER CARD" label in blue + - [x] **Expected**: Full-width card image for readability + +### 3.3 Card Fallback + +- [x] If player has no image: + - [x] **Expected**: Shows initials placeholder instead + +--- + +## Test 4: OutcomeWizard Progressive Disclosure + +### 4.1 Category Selection (Step 1) + +- [x] After rolling dice, locate outcome selection area +- [x] **Expected**: Three large category buttons visible: + - [x] "On Base" (green) + - [x] "Out" (red) + - [x] "X-Check" (orange) + +### 4.2 On Base Flow + +- [x] **Click** "On Base" +- [x] **Expected**: Sub-categories appear (Single, Double, Triple, Home Run, Walk, HBP) +- [x] **Click** "Single" +- [x] **Expected**: Specific outcomes appear (Single (1 base), Single (2 bases), Single (uncapped)) +- [x] **Click** "Back" button +- [x] **Expected**: Returns to sub-category selection +- [x] **Click** "Home Run" +- [x] **Expected**: Outcome submitted directly (no further steps needed) + +### 4.3 Out Flow with Location + +- [x] Start new play, roll dice +- [x] **Click** "Out" +- [x] **Click** "Groundout" +- [x] **Expected**: Specific groundout types appear (A, B, C) +- [x] **Click** "Groundball A" +- [x] **Expected**: Location picker appears (diamond with position buttons) +- [x] **Click** position button (e.g., "SS") +- [x] **Expected**: Outcome submitted with location + +### 4.4 X-Check Flow + +- [x] Start new play, roll dice +- [x] **Click** "X-Check" +- [x] **Expected**: Location picker appears immediately +- [x] **Click** any position +- [x] **Expected**: X-Check outcome submitted + +### 4.5 Cancel Flow + +- [x] Start outcome selection +- [x] **Click** "Cancel" button +- [x] **Expected**: Wizard resets to step 1 + +> **NOTE**: Future improvement needed - only show hit location picker when location actually matters for gameplay (e.g., fielder's choice, X-Check). Skip for outcomes where location is cosmetic only. + +--- + +## Test 5: Mobile-Specific Tests + +### 5.1 Touch Targets + +- [x] All buttons are at least 44x44px +- [x] Easy to tap without accidental touches + +### 5.2 Scrolling + +- [x] During normal gameplay, minimal scrolling required +- [x] Outcome wizard fits on screen without scrolling + +### 5.3 Swipe Gestures + +- [x] PlayerCardModal: Swipe down to close +- [x] BottomSheet (if integrated): Swipe down to minimize + +--- + +## Test 6: Dark Mode + +### 6.1 PlayerCardModal + +- [ ] Switch to dark mode (system preference) +- [ ] Open PlayerCardModal +- [ ] **Expected**: Dark background, readable text + +### 6.2 OutcomeWizard + +- [ ] **Expected**: Category buttons have appropriate dark mode colors +- [ ] **Expected**: Location picker readable in dark mode + +### 6.3 Post-Roll Card Display + +- [ ] **Expected**: Amber/orange styling visible in dark mode + +--- + +## Test 7: Edge Cases + +### 7.1 Missing Player Data + +- [x] If player image URL is broken or missing: + - [x] **Expected**: Falls back to initials display + - [x] **Expected**: No JS errors in console + +### 7.2 Rapid Interactions + +- [x] Click multiple cards quickly +- [x] **Expected**: Only one modal opens at a time + +### 7.3 Game State Transitions + +- [x] Complete a play +- [x] **Expected**: Outcome wizard resets for next play +- [x] **Expected**: Post-roll card display clears + +--- + +## Known Limitations + +1. **BottomSheet not yet integrated** with DecisionPanel - decision panels still render inline +2. **Substitute button** in PlayerCardModal is hidden (showSubstituteButton=false) +3. **Dark mode button visibility on iOS** - ActionButton, DiceRoller buttons missing visible backgrounds/borders on iOS Safari/Brave. CSS gradient backgrounds and ring borders not rendering. Needs further investigation (possibly iOS-specific CSS issue or Tailwind purging). + +--- + +## Test Results + +| Test | Status | Notes | +| ------------------------- | ------ | ----- | +| 1.1 Desktop Pitcher Card | ✅ PASS | | +| 1.2 Desktop Batter Card | ✅ PASS | | +| 1.3 Mobile Pitcher Card | ✅ PASS | | +| 1.4 Mobile Batter Card | ✅ PASS | | +| 2.1 Expand/Collapse | ✅ PASS | Fielders show player initials | +| 2.2 Tappable Pitcher | ✅ PASS | | +| 2.3 Tappable Batter | ✅ PASS | | +| 3.1 Batter Card (d6 1-3) | ✅ PASS | Full-width layout | +| 3.2 Pitcher Card (d6 4-6) | ✅ PASS | Full-width layout | +| 3.3 Card Fallback | ✅ PASS | | +| 4.1 Category Selection | ✅ PASS | | +| 4.2 On Base Flow | ✅ PASS | | +| 4.3 Out Flow + Location | ✅ PASS | Fixed batter advancement bug | +| 4.4 X-Check Flow | ✅ PASS | | +| 4.5 Cancel Flow | ✅ PASS | | +| 5.1 Touch Targets | ✅ PASS | | +| 5.2 Scrolling | ✅ PASS | | +| 5.3 Swipe Gestures | ✅ PASS | | +| 6.1 Dark Mode Modal | ✅ PASS | | +| 6.2 Dark Mode Wizard | ✅ PASS | | +| 6.3 Dark Mode Post-Roll | ⚠️ ISSUE | Buttons missing visible styling on iOS - needs investigation | +| 7.1 Missing Player Data | ✅ PASS | | +| 7.2 Rapid Interactions | ✅ PASS | | +| 7.3 State Transitions | ✅ PASS | | + +--- + +**Tester**: ******\_\_\_\_****** +**Date**: ******\_\_\_\_****** +**Build**: feature/game-page-polish diff --git a/frontend-sba/components/Game/CurrentSituation.vue b/frontend-sba/components/Game/CurrentSituation.vue index 081277d..4ddbf9f 100644 --- a/frontend-sba/components/Game/CurrentSituation.vue +++ b/frontend-sba/components/Game/CurrentSituation.vue @@ -3,13 +3,14 @@
-
-
+
{{ currentPitcher.position }} + Tap to view card
-
+
@@ -45,13 +47,14 @@
-
-
+
{{ currentBatter.position }} • Batting {{ currentBatter.batting_order }} + Tap to view card
-
+
+ + +
diff --git a/frontend-sba/components/Game/GamePlay.vue b/frontend-sba/components/Game/GamePlay.vue index 69cd889..dc881bf 100644 --- a/frontend-sba/components/Game/GamePlay.vue +++ b/frontend-sba/components/Game/GamePlay.vue @@ -73,6 +73,7 @@ :runners="runnersState" :current-batter="gameState?.current_batter" :current-pitcher="gameState?.current_pitcher" + :fielding-lineup="fieldingLineup" /> @@ -103,6 +104,8 @@ :can-submit-outcome="canSubmitOutcome" :outs="gameState?.outs ?? 0" :has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)" + :batter-player="batterPlayer" + :pitcher-player="pitcherPlayer" @roll-dice="handleRollDice" @submit-outcome="handleSubmitOutcome" @dismiss-result="handleDismissResult" @@ -134,6 +137,7 @@ :runners="runnersState" :current-batter="gameState?.current_batter" :current-pitcher="gameState?.current_pitcher" + :fielding-lineup="fieldingLineup" /> @@ -165,6 +169,8 @@ :can-submit-outcome="canSubmitOutcome" :outs="gameState?.outs ?? 0" :has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)" + :batter-player="batterPlayer" + :pitcher-player="pitcherPlayer" @roll-dice="handleRollDice" @submit-outcome="handleSubmitOutcome" @dismiss-result="handleDismissResult" @@ -376,6 +382,21 @@ const pendingRoll = computed(() => gameStore.pendingRoll) const lastPlayResult = computed(() => gameStore.lastPlayResult) const currentDecisionPrompt = computed(() => gameStore.currentDecisionPrompt) +// Resolved player data for post-roll card display +const batterPlayer = computed(() => { + const batterState = gameState.value?.current_batter + if (!batterState) return null + const lineup = gameStore.findPlayerInLineup(batterState.lineup_id) + return lineup?.player ?? null +}) + +const pitcherPlayer = computed(() => { + const pitcherState = gameState.value?.current_pitcher + if (!pitcherState) return null + const lineup = gameStore.findPlayerInLineup(pitcherState.lineup_id) + return lineup?.player ?? null +}) + // Local UI state const isLoading = ref(true) const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting') @@ -420,6 +441,23 @@ const runnersData = computed(() => { } }) +// Get active fielding lineup for GameBoard +const fieldingLineup = computed(() => { + if (!gameState.value) return [] + + // Top of inning: home team fields; Bottom: away team fields + const fieldingTeamId = gameState.value.half === 'top' + ? gameState.value.home_team_id + : gameState.value.away_team_id + + const lineup = fieldingTeamId === gameState.value.home_team_id + ? gameStore.homeLineup + : gameStore.awayLineup + + // Return only active players + return lineup.filter(p => p.is_active) +}) + const currentTeam = computed(() => { return gameState.value?.half === 'top' ? 'away' : 'home' }) diff --git a/frontend-sba/components/Gameplay/DiceRoller.vue b/frontend-sba/components/Gameplay/DiceRoller.vue index 4aacc2d..90f6806 100644 --- a/frontend-sba/components/Gameplay/DiceRoller.vue +++ b/frontend-sba/components/Gameplay/DiceRoller.vue @@ -256,6 +256,18 @@ const formatTimestamp = (timestamp: string): string => { /* Dark mode support */ @media (prefers-color-scheme: dark) { + .roll-button { + @apply ring-2 ring-offset-2 ring-offset-gray-900; + } + + .roll-button-enabled { + @apply ring-green-400; + } + + .roll-button-disabled { + @apply bg-gray-700 text-gray-400 ring-gray-600; + } + .dice-results { @apply from-blue-800 to-blue-900; } diff --git a/frontend-sba/components/Gameplay/GameplayPanel.vue b/frontend-sba/components/Gameplay/GameplayPanel.vue index 9dd4fc9..51d7c78 100644 --- a/frontend-sba/components/Gameplay/GameplayPanel.vue +++ b/frontend-sba/components/Gameplay/GameplayPanel.vue @@ -56,13 +56,36 @@ :pending-roll="pendingRoll" /> + +
+ +
+ + {{ showBatterCard ? 'BATTER' : 'PITCHER' }} CARD + + {{ activeCardPlayer.name }} +
+ + +
+ +
+ + {{ activeCardPlayer.name?.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() || '?' }} + +
+
+
+
- @@ -104,9 +127,9 @@ + + diff --git a/frontend-sba/components/Player/PlayerCardModal.vue b/frontend-sba/components/Player/PlayerCardModal.vue new file mode 100644 index 0000000..2a5e846 --- /dev/null +++ b/frontend-sba/components/Player/PlayerCardModal.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/frontend-sba/components/UI/ActionButton.vue b/frontend-sba/components/UI/ActionButton.vue index 3a9eb65..5138703 100644 --- a/frontend-sba/components/UI/ActionButton.vue +++ b/frontend-sba/components/UI/ActionButton.vue @@ -88,4 +88,36 @@ button * { user-select: none !important; -webkit-touch-callout: none !important; } + +/* Dark mode - add visible borders since gradients may not render on some browsers */ +@media (prefers-color-scheme: dark) { + button { + @apply ring-2 ring-offset-1 ring-offset-gray-900; + } + + /* Variant-specific ring colors */ + button[class*="from-green"] { + @apply ring-green-400; + } + + button[class*="from-blue"], button[class*="from-primary"] { + @apply ring-blue-400; + } + + button[class*="from-red"] { + @apply ring-red-400; + } + + button[class*="from-yellow"] { + @apply ring-yellow-400; + } + + button[class*="from-gray"] { + @apply ring-gray-400; + } + + button:disabled { + @apply ring-gray-600; + } +} diff --git a/frontend-sba/components/UI/BottomSheet.vue b/frontend-sba/components/UI/BottomSheet.vue new file mode 100644 index 0000000..36fb79c --- /dev/null +++ b/frontend-sba/components/UI/BottomSheet.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/frontend-sba/constants/outcomeFlow.ts b/frontend-sba/constants/outcomeFlow.ts new file mode 100644 index 0000000..2b3b72e --- /dev/null +++ b/frontend-sba/constants/outcomeFlow.ts @@ -0,0 +1,220 @@ +/** + * Outcome Flow Constants + * + * Defines the hierarchical structure for the progressive disclosure + * outcome selection wizard. Three top-level categories branch into + * specific outcome types. + * + * Categories: + * - ON_BASE: Hits and walks that result in batter reaching base + * - OUT: Various types of outs + * - X_CHECK: Defensive X-Check resolution (errors result from this) + */ + +import type { PlayOutcome } from '~/types/game' + +/** + * Top-level outcome categories for step 1 selection + */ +export type OutcomeCategory = 'ON_BASE' | 'OUT' | 'X_CHECK' + +/** + * Sub-categories for ON_BASE outcomes + */ +export type OnBaseSubCategory = 'SINGLE' | 'DOUBLE' | 'TRIPLE' | 'HOME_RUN' | 'WALK' | 'HBP' + +/** + * Sub-categories for OUT outcomes + */ +export type OutSubCategory = 'STRIKEOUT' | 'GROUNDOUT' | 'FLYOUT' | 'LINEOUT' | 'POPOUT' + +/** + * Category configuration with display info + */ +export interface CategoryConfig { + label: string + description: string + color: string + bgColor: string + borderColor: string +} + +/** + * Sub-category configuration with outcomes + */ +export interface SubCategoryConfig { + label: string + outcomes: PlayOutcome[] +} + +/** + * Category display configuration + */ +export const CATEGORY_CONFIG: Record = { + ON_BASE: { + label: 'On Base', + description: 'Hit, walk, or HBP', + color: 'text-green-700', + bgColor: 'bg-green-50 hover:bg-green-100', + borderColor: 'border-green-300', + }, + OUT: { + label: 'Out', + description: 'Strikeout, groundout, flyout', + color: 'text-red-700', + bgColor: 'bg-red-50 hover:bg-red-100', + borderColor: 'border-red-300', + }, + X_CHECK: { + label: 'X-Check', + description: 'Defensive play check', + color: 'text-orange-700', + bgColor: 'bg-orange-50 hover:bg-orange-100', + borderColor: 'border-orange-300', + }, +} + +/** + * ON_BASE sub-categories and their specific outcomes + */ +export const ON_BASE_OUTCOMES: Record = { + SINGLE: { + label: 'Single', + outcomes: ['single_1', 'single_2', 'single_uncapped'], + }, + DOUBLE: { + label: 'Double', + outcomes: ['double_2', 'double_3', 'double_uncapped'], + }, + TRIPLE: { + label: 'Triple', + outcomes: ['triple'], + }, + HOME_RUN: { + label: 'Home Run', + outcomes: ['homerun'], + }, + WALK: { + label: 'Walk', + outcomes: ['walk', 'intentional_walk'], + }, + HBP: { + label: 'Hit By Pitch', + outcomes: ['hbp'], + }, +} + +/** + * OUT sub-categories and their specific outcomes + */ +export const OUT_OUTCOMES: Record = { + STRIKEOUT: { + label: 'Strikeout', + outcomes: ['strikeout'], + }, + GROUNDOUT: { + label: 'Groundout', + outcomes: ['groundball_a', 'groundball_b', 'groundball_c'], + }, + FLYOUT: { + label: 'Flyout', + outcomes: ['flyout_a', 'flyout_b', 'flyout_bq', 'flyout_c'], + }, + LINEOUT: { + label: 'Lineout', + outcomes: ['lineout'], + }, + POPOUT: { + label: 'Popout', + outcomes: ['popout'], + }, +} + +/** + * X-CHECK outcome (singular - error is a result, not input) + */ +export const X_CHECK_OUTCOMES: PlayOutcome[] = ['x_check'] + +/** + * Outcome display labels for final selection + */ +export const OUTCOME_LABELS: Partial> = { + // Singles + single_1: 'Single (1 base)', + single_2: 'Single (2 bases)', + single_uncapped: 'Single (uncapped)', + + // Doubles + double_2: 'Double (2 bases)', + double_3: 'Double (3 bases)', + double_uncapped: 'Double (uncapped)', + + // Other hits + triple: 'Triple', + homerun: 'Home Run', + + // Walks + walk: 'Walk', + intentional_walk: 'Intentional Walk', + hbp: 'Hit By Pitch', + + // Outs + strikeout: 'Strikeout', + groundball_a: 'Groundball A', + groundball_b: 'Groundball B', + groundball_c: 'Groundball C', + flyout_a: 'Flyout A', + flyout_b: 'Flyout B', + flyout_bq: 'Flyout B*', + flyout_c: 'Flyout C', + lineout: 'Lineout', + popout: 'Popout', + + // X-Check + x_check: 'X-Check', +} + +/** + * Hit location positions for location selection step + */ +export const HIT_LOCATIONS = [ + { id: 'P', label: 'P', position: 'Pitcher' }, + { id: 'C', label: 'C', position: 'Catcher' }, + { id: '1B', label: '1B', position: 'First Base' }, + { id: '2B', label: '2B', position: 'Second Base' }, + { id: 'SS', label: 'SS', position: 'Shortstop' }, + { id: '3B', label: '3B', position: 'Third Base' }, + { id: 'LF', label: 'LF', position: 'Left Field' }, + { id: 'CF', label: 'CF', position: 'Center Field' }, + { id: 'RF', label: 'RF', position: 'Right Field' }, +] as const + +/** + * Outcomes that require hit location selection + */ +export const OUTCOMES_REQUIRING_LOCATION: PlayOutcome[] = [ + 'groundball_a', + 'groundball_b', + 'groundball_c', + 'flyout_a', + 'flyout_b', + 'flyout_bq', + 'flyout_c', + 'lineout', + 'popout', + 'x_check', +] + +/** + * Check if an outcome requires a hit location + */ +export function requiresHitLocation(outcome: PlayOutcome): boolean { + return OUTCOMES_REQUIRING_LOCATION.includes(outcome) +} + +/** + * Get the display label for an outcome + */ +export function getOutcomeLabel(outcome: PlayOutcome): string { + return OUTCOME_LABELS[outcome] || outcome +} diff --git a/frontend-sba/tests/unit/components/Gameplay/GameplayPanel.spec.ts b/frontend-sba/tests/unit/components/Gameplay/GameplayPanel.spec.ts index 006029b..555e52b 100644 --- a/frontend-sba/tests/unit/components/Gameplay/GameplayPanel.spec.ts +++ b/frontend-sba/tests/unit/components/Gameplay/GameplayPanel.spec.ts @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils' import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue' import type { RollData, PlayResult } from '~/types' import DiceRoller from '~/components/Gameplay/DiceRoller.vue' -import ManualOutcomeEntry from '~/components/Gameplay/ManualOutcomeEntry.vue' +import OutcomeWizard from '~/components/Gameplay/OutcomeWizard.vue' import PlayResultComponent from '~/components/Gameplay/PlayResult.vue' describe('GameplayPanel', () => { @@ -201,7 +201,7 @@ describe('GameplayPanel', () => { expect(diceRoller.props('canRoll')).toBe(false) }) - it('renders ManualOutcomeEntry component', () => { + it('renders OutcomeWizard component', () => { const wrapper = mount(GameplayPanel, { props: { ...defaultProps, @@ -210,7 +210,7 @@ describe('GameplayPanel', () => { }, }) - expect(wrapper.findComponent(ManualOutcomeEntry).exists()).toBe(true) + expect(wrapper.findComponent(OutcomeWizard).exists()).toBe(true) }) it('displays active status when outcome entry active', () => { @@ -315,9 +315,9 @@ describe('GameplayPanel', () => { }, }) - const outcomeEntry = wrapper.findComponent(ManualOutcomeEntry) + const outcomeWizard = wrapper.findComponent(OutcomeWizard) const payload = { outcome: 'STRIKEOUT' as const, hitLocation: undefined } - await outcomeEntry.vm.$emit('submit', payload) + await outcomeWizard.vm.$emit('submit', payload) expect(wrapper.emitted('submitOutcome')).toBeTruthy() expect(wrapper.emitted('submitOutcome')?.[0]).toEqual([payload])