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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-23 15:23:38 -06:00
parent 52706bed40
commit be31e2ccb4
16 changed files with 2384 additions and 62 deletions

View File

@ -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 "

View File

@ -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.

View File

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

View File

@ -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.)"""

View File

@ -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

View File

@ -3,13 +3,14 @@
<!-- Mobile Layout (Stacked) -->
<div class="lg:hidden space-y-3">
<!-- Current Pitcher Card -->
<div
<button
v-if="currentPitcher"
class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-4 border-2 border-blue-200 dark:border-blue-700 shadow-md"
class="w-full text-left bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-4 border-2 border-blue-200 dark:border-blue-700 shadow-md hover:shadow-lg transition-shadow cursor-pointer"
@click="openPlayerCard('pitcher')"
>
<div class="flex items-center gap-3">
<!-- Pitcher Image/Badge -->
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden">
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden ring-2 ring-blue-300 ring-offset-1">
<img
v-if="getPlayerPreviewImage(pitcherPlayer)"
:src="getPlayerPreviewImage(pitcherPlayer)!"
@ -32,10 +33,11 @@
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ currentPitcher.position }}
<span class="ml-2 text-blue-500">Tap to view card</span>
</div>
</div>
</div>
</div>
</button>
<!-- VS Indicator -->
<div class="flex items-center justify-center">
@ -45,13 +47,14 @@
</div>
<!-- Current Batter Card -->
<div
<button
v-if="currentBatter"
class="bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-xl p-4 border-2 border-red-200 dark:border-red-700 shadow-md"
class="w-full text-left bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-xl p-4 border-2 border-red-200 dark:border-red-700 shadow-md hover:shadow-lg transition-shadow cursor-pointer"
@click="openPlayerCard('batter')"
>
<div class="flex items-center gap-3">
<!-- Batter Image/Badge -->
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden">
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden ring-2 ring-red-300 ring-offset-1">
<img
v-if="getPlayerPreviewImage(batterPlayer)"
:src="getPlayerPreviewImage(batterPlayer)!"
@ -75,22 +78,24 @@
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ currentBatter.position }}
<span v-if="currentBatter.batting_order" class="ml-1"> Batting {{ currentBatter.batting_order }}</span>
<span class="ml-2 text-red-500">Tap to view card</span>
</div>
</div>
</div>
</div>
</button>
</div>
<!-- Desktop Layout (Side-by-Side) -->
<div class="hidden lg:grid lg:grid-cols-2 gap-6">
<!-- Current Pitcher Card -->
<div
<button
v-if="currentPitcher"
class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-6 border-2 border-blue-200 dark:border-blue-700 shadow-lg"
class="text-left bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-6 border-2 border-blue-200 dark:border-blue-700 shadow-lg hover:shadow-xl transition-shadow cursor-pointer"
@click="openPlayerCard('pitcher')"
>
<div class="flex items-start gap-4">
<!-- Pitcher Image/Badge -->
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden">
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden ring-2 ring-blue-300 ring-offset-2">
<img
v-if="getPlayerPreviewImage(pitcherPlayer)"
:src="getPlayerPreviewImage(pitcherPlayer)!"
@ -113,19 +118,21 @@
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ currentPitcher.position }}
<span class="ml-2 text-blue-500">Click to view card</span>
</div>
</div>
</div>
</div>
</button>
<!-- Current Batter Card -->
<div
<button
v-if="currentBatter"
class="bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-xl p-6 border-2 border-red-200 dark:border-red-700 shadow-lg"
class="text-left bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-xl p-6 border-2 border-red-200 dark:border-red-700 shadow-lg hover:shadow-xl transition-shadow cursor-pointer"
@click="openPlayerCard('batter')"
>
<div class="flex items-start gap-4">
<!-- Batter Image/Badge -->
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden">
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden ring-2 ring-red-300 ring-offset-2">
<img
v-if="getPlayerPreviewImage(batterPlayer)"
:src="getPlayerPreviewImage(batterPlayer)!"
@ -149,10 +156,11 @@
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ currentBatter.position }}
<span v-if="currentBatter.batting_order" class="ml-2"> Batting {{ currentBatter.batting_order }}</span>
<span class="ml-2 text-red-500">Click to view card</span>
</div>
</div>
</div>
</div>
</button>
</div>
<!-- Empty State -->
@ -168,13 +176,24 @@
<p class="text-gray-500 dark:text-gray-400 font-medium">Waiting for game to start...</p>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Players will appear here once the game begins.</p>
</div>
<!-- Player Card Modal -->
<PlayerCardModal
:is-open="isPlayerCardOpen"
:player="selectedPlayerData"
:position="selectedPlayerPosition"
:team-name="selectedPlayerTeam"
:show-substitute-button="false"
@close="closePlayerCard"
/>
</div>
</template>
<script setup lang="ts">
import { computed, watch, toRefs } from 'vue'
import { computed, watch, toRefs, ref } from 'vue'
import type { LineupPlayerState } from '~/types/game'
import { useGameStore } from '~/store/game'
import PlayerCardModal from '~/components/Player/PlayerCardModal.vue'
interface Props {
currentBatter?: LineupPlayerState | null
@ -244,6 +263,40 @@ function getPlayerFallbackInitial(player: { name: string } | null): string {
if (parts.length === 1) return parts[0].charAt(0).toUpperCase()
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
}
// Player card modal state
const isPlayerCardOpen = ref(false)
const selectedPlayerData = ref<{
id: number
name: string
image: string
headshot?: string
} | null>(null)
const selectedPlayerPosition = ref('')
const selectedPlayerTeam = ref('')
// Open player card modal for batter or pitcher
function openPlayerCard(type: 'batter' | 'pitcher') {
const player = type === 'batter' ? batterPlayer.value : pitcherPlayer.value
const state = type === 'batter' ? props.currentBatter : props.currentPitcher
if (!player) return
selectedPlayerData.value = {
id: player.id,
name: player.name,
image: player.image || '',
headshot: player.headshot || undefined
}
selectedPlayerPosition.value = state?.position || ''
selectedPlayerTeam.value = '' // Could be enhanced to show team name
isPlayerCardOpen.value = true
}
function closePlayerCard() {
isPlayerCardOpen.value = false
selectedPlayerData.value = null
}
</script>
<style scoped>

View File

@ -31,9 +31,10 @@
</div>
<!-- Current Pitcher (on mound) -->
<div
<button
v-if="currentPitcher"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 mt-12"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 mt-12 cursor-pointer hover:scale-105 transition-transform focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 rounded-lg"
@click="openPlayerCard('pitcher')"
>
<div class="text-center">
<div class="w-8 h-8 mx-auto bg-blue-500 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white text-xs font-bold">
@ -43,7 +44,7 @@
{{ getPitcherName }}
</div>
</div>
</div>
</button>
<!-- Home Plate -->
<div class="absolute bottom-[14%] left-1/2 -translate-x-1/2">
@ -52,9 +53,10 @@
<div class="w-8 h-8 bg-white rotate-45 shadow-xl border-2 border-gray-200"/>
<!-- Current Batter -->
<div
<button
v-if="currentBatter"
class="absolute -bottom-14 left-1/2 -translate-x-1/2 w-32"
class="absolute -bottom-14 left-1/2 -translate-x-1/2 w-32 cursor-pointer hover:scale-105 transition-transform focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2 rounded-lg"
@click="openPlayerCard('batter')"
>
<div class="text-center">
<div class="w-8 h-8 mx-auto bg-red-500 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white text-xs font-bold mb-1">
@ -67,7 +69,7 @@
Batting {{ currentBatter.batting_order }}
</div>
</div>
</div>
</button>
</div>
</div>
@ -138,6 +140,103 @@
<div class="absolute inset-0 opacity-10 pointer-events-none">
<div class="absolute inset-0" style="background: repeating-linear-gradient(90deg, transparent 0px, transparent 20px, rgba(0,0,0,0.05) 20px, rgba(0,0,0,0.05) 40px)"/>
</div>
<!-- Expanded View: All 9 Fielder Positions -->
<template v-if="isExpanded">
<!-- Catcher (behind home) -->
<button
class="absolute bottom-[6%] left-1/2 -translate-x-1/2 fielder-button"
:class="{ 'fielder-active': getFielderInfo('C').exists }"
:title="getFielderInfo('C').name || 'Catcher'"
@click="openFielderCard('C')"
>
<span class="fielder-label">{{ getFielderInfo('C').initials }}</span>
</button>
<!-- First Baseman -->
<button
class="absolute top-[45%] right-[20%] fielder-button"
:class="{ 'fielder-active': getFielderInfo('1B').exists }"
:title="getFielderInfo('1B').name || 'First Base'"
@click="openFielderCard('1B')"
>
<span class="fielder-label">{{ getFielderInfo('1B').initials }}</span>
</button>
<!-- Second Baseman -->
<button
class="absolute top-[35%] right-[35%] fielder-button"
:class="{ 'fielder-active': getFielderInfo('2B').exists }"
:title="getFielderInfo('2B').name || 'Second Base'"
@click="openFielderCard('2B')"
>
<span class="fielder-label">{{ getFielderInfo('2B').initials }}</span>
</button>
<!-- Shortstop -->
<button
class="absolute top-[35%] left-[35%] fielder-button"
:class="{ 'fielder-active': getFielderInfo('SS').exists }"
:title="getFielderInfo('SS').name || 'Shortstop'"
@click="openFielderCard('SS')"
>
<span class="fielder-label">{{ getFielderInfo('SS').initials }}</span>
</button>
<!-- Third Baseman -->
<button
class="absolute top-[45%] left-[20%] fielder-button"
:class="{ 'fielder-active': getFielderInfo('3B').exists }"
:title="getFielderInfo('3B').name || 'Third Base'"
@click="openFielderCard('3B')"
>
<span class="fielder-label">{{ getFielderInfo('3B').initials }}</span>
</button>
<!-- Left Fielder -->
<button
class="absolute top-[15%] left-[15%] fielder-button fielder-outfield"
:class="{ 'fielder-active': getFielderInfo('LF').exists }"
:title="getFielderInfo('LF').name || 'Left Field'"
@click="openFielderCard('LF')"
>
<span class="fielder-label">{{ getFielderInfo('LF').initials }}</span>
</button>
<!-- Center Fielder -->
<button
class="absolute top-[8%] left-1/2 -translate-x-1/2 fielder-button fielder-outfield"
:class="{ 'fielder-active': getFielderInfo('CF').exists }"
:title="getFielderInfo('CF').name || 'Center Field'"
@click="openFielderCard('CF')"
>
<span class="fielder-label">{{ getFielderInfo('CF').initials }}</span>
</button>
<!-- Right Fielder -->
<button
class="absolute top-[15%] right-[15%] fielder-button fielder-outfield"
:class="{ 'fielder-active': getFielderInfo('RF').exists }"
:title="getFielderInfo('RF').name || 'Right Field'"
@click="openFielderCard('RF')"
>
<span class="fielder-label">{{ getFielderInfo('RF').initials }}</span>
</button>
</template>
<!-- Expand/Collapse Button -->
<button
class="absolute bottom-2 right-2 w-8 h-8 bg-white/90 hover:bg-white rounded-full shadow-lg flex items-center justify-center text-gray-700 transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-400"
@click="toggleExpanded"
:title="isExpanded ? 'Collapse field view' : 'Expand to see all fielders'"
>
<svg v-if="!isExpanded" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
@ -147,41 +246,52 @@
<!-- Current Batter Card -->
<div
v-if="currentBatter"
class="bg-red-50 border-2 border-red-200 rounded-lg p-3"
class="info-card info-card-batter"
>
<div class="flex items-center gap-2 mb-1">
<div class="w-6 h-6 bg-red-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
B
</div>
<div class="text-xs font-semibold text-red-900">AT BAT</div>
<div class="info-card-label info-card-label-batter">AT BAT</div>
</div>
<div class="text-sm font-bold text-gray-900">{{ getBatterName }}</div>
<div class="text-xs text-gray-600">{{ currentBatter.position }} #{{ currentBatter.batting_order }}</div>
<div class="info-card-name">{{ getBatterName }}</div>
<div class="info-card-detail">{{ currentBatter.position }} #{{ currentBatter.batting_order }}</div>
</div>
<!-- Current Pitcher Card -->
<div
v-if="currentPitcher"
class="bg-blue-50 border-2 border-blue-200 rounded-lg p-3"
class="info-card info-card-pitcher"
>
<div class="flex items-center gap-2 mb-1">
<div class="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
P
</div>
<div class="text-xs font-semibold text-blue-900">PITCHING</div>
<div class="info-card-label info-card-label-pitcher">PITCHING</div>
</div>
<div class="text-sm font-bold text-gray-900">{{ getPitcherName }}</div>
<div class="text-xs text-gray-600">{{ currentPitcher.position }}</div>
<div class="info-card-name">{{ getPitcherName }}</div>
<div class="info-card-detail">{{ currentPitcher.position }}</div>
</div>
</div>
</div>
<!-- Player Card Modal -->
<PlayerCardModal
:is-open="isPlayerCardOpen"
:player="selectedPlayerData"
:position="selectedPlayerPosition"
:show-substitute-button="false"
@close="closePlayerCard"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import type { LineupPlayerState } from '~/types/game'
import type { Lineup } from '~/types/player'
import { useGameStore } from '~/store/game'
import PlayerCardModal from '~/components/Player/PlayerCardModal.vue'
interface Props {
runners?: {
@ -191,16 +301,29 @@ interface Props {
}
currentBatter?: LineupPlayerState | null
currentPitcher?: LineupPlayerState | null
fieldingLineup?: Lineup[]
}
const props = withDefaults(defineProps<Props>(), {
runners: () => ({ first: false, second: false, third: false }),
currentBatter: null,
currentPitcher: null
currentPitcher: null,
fieldingLineup: () => []
})
const gameStore = useGameStore()
// UI State
const isExpanded = ref(false)
const isPlayerCardOpen = ref(false)
const selectedPlayerData = ref<{
id: number
name: string
image: string
headshot?: string
} | null>(null)
const selectedPlayerPosition = ref('')
// Resolve player data from lineup using lineup_id
const batterPlayer = computed(() => {
if (!props.currentBatter) return null
@ -217,6 +340,66 @@ const pitcherPlayer = computed(() => {
// Helper to get player name with fallback
const getBatterName = computed(() => batterPlayer.value?.name ?? `Player #${props.currentBatter?.lineup_id}`)
const getPitcherName = computed(() => pitcherPlayer.value?.name ?? `Player #${props.currentPitcher?.lineup_id}`)
// Toggle expanded view
function toggleExpanded() {
isExpanded.value = !isExpanded.value
}
// Open player card modal
function openPlayerCard(type: 'batter' | 'pitcher') {
const player = type === 'batter' ? batterPlayer.value : pitcherPlayer.value
const state = type === 'batter' ? props.currentBatter : props.currentPitcher
if (!player) return
selectedPlayerData.value = {
id: player.id,
name: player.name,
image: player.image || '',
headshot: player.headshot || undefined
}
selectedPlayerPosition.value = state?.position || (type === 'pitcher' ? 'P' : '')
isPlayerCardOpen.value = true
}
function closePlayerCard() {
isPlayerCardOpen.value = false
selectedPlayerData.value = null
}
// Get fielder by position from lineup
function getFielderByPosition(position: string): Lineup | null {
return props.fieldingLineup.find(p => p.position === position) || null
}
// Open fielder card modal
function openFielderCard(position: string) {
const fielder = getFielderByPosition(position)
if (!fielder) return
selectedPlayerData.value = {
id: fielder.player.id,
name: fielder.player.name,
image: fielder.player.image || '',
headshot: fielder.player.headshot || undefined
}
selectedPlayerPosition.value = position
isPlayerCardOpen.value = true
}
// Get fielder display info (name initials and whether they exist)
function getFielderInfo(position: string): { initials: string; name: string; exists: boolean } {
const fielder = getFielderByPosition(position)
if (!fielder) {
return { initials: position, name: '', exists: false }
}
const nameParts = fielder.player.name.split(' ')
const initials = nameParts.length >= 2
? `${nameParts[0][0]}${nameParts[nameParts.length - 1][0]}`
: nameParts[0].substring(0, 2)
return { initials: initials.toUpperCase(), name: fielder.player.name, exists: true }
}
</script>
<style scoped>
@ -235,4 +418,90 @@ const getPitcherName = computed(() => pitcherPlayer.value?.name ?? `Player #${pr
.animate-pulse-subtle {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* Fielder buttons in expanded view */
.fielder-button {
@apply w-7 h-7 rounded-full border-2 border-white shadow-lg;
@apply flex items-center justify-center;
@apply bg-gray-500 text-white;
@apply cursor-pointer transition-all duration-200;
@apply hover:scale-110 hover:bg-gray-600;
@apply focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-1;
}
.fielder-button.fielder-active {
@apply bg-blue-600 hover:bg-blue-700;
}
.fielder-button.fielder-outfield {
@apply bg-green-600;
}
.fielder-button.fielder-outfield.fielder-active {
@apply bg-emerald-600 hover:bg-emerald-700;
}
.fielder-label {
@apply text-[9px] font-bold leading-none;
}
/* Mobile info cards */
.info-card {
@apply rounded-lg p-3 border-2;
}
.info-card-batter {
@apply bg-red-50 border-red-200;
}
.info-card-pitcher {
@apply bg-blue-50 border-blue-200;
}
.info-card-label {
@apply text-xs font-semibold;
}
.info-card-label-batter {
@apply text-red-900;
}
.info-card-label-pitcher {
@apply text-blue-900;
}
.info-card-name {
@apply text-sm font-bold text-gray-900;
}
.info-card-detail {
@apply text-xs text-gray-600;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.info-card-batter {
@apply bg-red-900/30 border-red-700;
}
.info-card-pitcher {
@apply bg-blue-900/30 border-blue-700;
}
.info-card-label-batter {
@apply text-red-300;
}
.info-card-label-pitcher {
@apply text-blue-300;
}
.info-card-name {
@apply text-gray-100;
}
.info-card-detail {
@apply text-gray-400;
}
}
</style>

View File

@ -73,6 +73,7 @@
:runners="runnersState"
:current-batter="gameState?.current_batter"
:current-pitcher="gameState?.current_pitcher"
:fielding-lineup="fieldingLineup"
/>
<!-- Decision Panel (Phase F3) -->
@ -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"
/>
</div>
@ -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'
})

View File

@ -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;
}

View File

@ -56,13 +56,36 @@
:pending-roll="pendingRoll"
/>
<!-- Post-Roll Card Display -->
<div v-if="activeCardPlayer" class="post-roll-card">
<!-- Card Header -->
<div class="card-header">
<span class="card-label" :class="showBatterCard ? 'batter-label' : 'pitcher-label'">
{{ showBatterCard ? 'BATTER' : 'PITCHER' }} CARD
</span>
<span class="player-name">{{ activeCardPlayer.name }}</span>
</div>
<!-- Full-Width Card Image -->
<div class="card-image-wrapper">
<img
v-if="activeCardPlayer.image"
:src="activeCardPlayer.image"
:alt="`${activeCardPlayer.name} card`"
class="player-card-image"
>
<div v-else class="card-placeholder">
<span class="placeholder-initials">
{{ activeCardPlayer.name?.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() || '?' }}
</span>
</div>
</div>
</div>
<div class="divider"/>
<ManualOutcomeEntry
:roll-data="pendingRoll"
<OutcomeWizard
:can-submit="canSubmitOutcome"
:outs="outs"
:has-runners="hasRunners"
@submit="handleSubmitOutcome"
@cancel="handleCancelOutcome"
/>
@ -104,9 +127,9 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { RollData, PlayResult, PlayOutcome } from '~/types'
import type { RollData, PlayResult, PlayOutcome, SbaPlayer } from '~/types'
import DiceRoller from './DiceRoller.vue'
import ManualOutcomeEntry from './ManualOutcomeEntry.vue'
import OutcomeWizard from './OutcomeWizard.vue'
import PlayResultDisplay from './PlayResult.vue'
interface Props {
@ -118,11 +141,16 @@ interface Props {
canSubmitOutcome: boolean
outs?: number
hasRunners?: boolean
// Player data for post-roll card display
batterPlayer?: SbaPlayer | null
pitcherPlayer?: SbaPlayer | null
}
const props = withDefaults(defineProps<Props>(), {
outs: 0,
hasRunners: false,
batterPlayer: null,
pitcherPlayer: null,
})
const emit = defineEmits<{
@ -135,6 +163,14 @@ const emit = defineEmits<{
const error = ref<string | null>(null)
const isSubmitting = ref(false)
// Post-roll card display: d6_one 1-3 = batter, 4-6 = pitcher
const showBatterCard = computed(() =>
props.pendingRoll && props.pendingRoll.d6_one <= 3
)
const activeCardPlayer = computed(() =>
showBatterCard.value ? props.batterPlayer : props.pitcherPlayer
)
// Workflow state computation
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result'
@ -300,6 +336,50 @@ const handleDismissResult = () => {
@apply space-y-6;
}
/* Post-Roll Card Display */
.post-roll-card {
@apply bg-gradient-to-br from-amber-50 to-orange-50 rounded-xl p-4;
@apply border-2 border-amber-200 shadow-md;
}
.card-header {
@apply flex items-center gap-3 mb-3;
}
.card-label {
@apply text-xs font-bold uppercase tracking-wider px-2 py-0.5 rounded;
}
.batter-label {
@apply bg-red-100 text-red-700;
}
.pitcher-label {
@apply bg-blue-100 text-blue-700;
}
.card-header .player-name {
@apply text-lg font-bold text-gray-900;
}
.card-image-wrapper {
@apply w-full rounded-lg overflow-hidden shadow-lg;
@apply ring-2 ring-amber-300 ring-offset-2;
}
.player-card-image {
@apply w-full h-auto object-contain;
}
.card-placeholder {
@apply w-full h-48 bg-gradient-to-br from-gray-300 to-gray-400;
@apply flex items-center justify-center;
}
.placeholder-initials {
@apply text-4xl font-bold text-gray-600;
}
.divider {
@apply border-t-2 border-gray-200;
}
@ -318,11 +398,12 @@ const handleDismissResult = () => {
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.panel-container {
@apply from-gray-800 to-gray-900 border-gray-700;
@apply from-gray-800 to-gray-900 border-gray-600;
@apply ring-1 ring-gray-700;
}
.panel-header {
@apply bg-gray-800 border-gray-700;
@apply bg-gray-800 border-gray-600;
}
.panel-title {
@ -352,6 +433,35 @@ const handleDismissResult = () => {
.error-message {
@apply bg-red-900 bg-opacity-30 border-red-700 text-red-300;
}
/* Post-roll card dark mode */
.post-roll-card {
@apply from-amber-900/30 to-orange-900/30 border-amber-700;
}
.card-header .player-name {
@apply text-gray-100;
}
.card-image-wrapper {
@apply ring-amber-600;
}
.card-placeholder {
@apply from-gray-600 to-gray-700;
}
.placeholder-initials {
@apply text-gray-300;
}
.batter-label {
@apply bg-red-900/50 text-red-300;
}
.pitcher-label {
@apply bg-blue-900/50 text-blue-300;
}
}
/* Mobile optimizations */

View File

@ -0,0 +1,504 @@
<template>
<div class="outcome-wizard">
<!-- Progress Indicator -->
<div class="progress-bar">
<div class="progress-steps">
<div
v-for="step in totalSteps"
:key="step"
class="progress-step"
:class="{ active: currentStep >= step, current: currentStep === step }"
/>
</div>
<button
v-if="currentStep > 1"
class="back-button"
@click="goBack"
>
<svg class="w-4 h-4" 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>
Back
</button>
</div>
<!-- Step 1: Category Selection -->
<div v-if="currentStep === 1" class="step-content">
<h3 class="step-title">Select Outcome Type</h3>
<div class="category-grid">
<button
v-for="(config, category) in CATEGORY_CONFIG"
:key="category"
class="category-button"
:class="[config.bgColor, config.borderColor]"
@click="selectCategory(category as OutcomeCategory)"
>
<span class="category-label" :class="config.color">{{ config.label }}</span>
<span class="category-description">{{ config.description }}</span>
</button>
</div>
</div>
<!-- Step 2: Sub-Category Selection (ON_BASE or OUT) -->
<div v-else-if="currentStep === 2 && selectedCategory !== 'X_CHECK'" class="step-content">
<h3 class="step-title">
{{ selectedCategory === 'ON_BASE' ? 'Select Hit Type' : 'Select Out Type' }}
</h3>
<div class="subcategory-grid">
<button
v-for="(config, subCategory) in currentSubCategories"
:key="subCategory"
class="subcategory-button"
@click="selectSubCategory(subCategory)"
>
{{ config.label }}
</button>
</div>
</div>
<!-- Step 3: Specific Outcome Selection (if multiple options) -->
<div v-else-if="currentStep === 3 && currentOutcomes.length > 1" class="step-content">
<h3 class="step-title">Select Specific Outcome</h3>
<div class="outcome-grid">
<button
v-for="outcome in currentOutcomes"
:key="outcome"
class="outcome-button"
@click="selectOutcome(outcome)"
>
{{ getOutcomeLabel(outcome) }}
</button>
</div>
</div>
<!-- Step 4: Hit Location Selection (when required) -->
<div v-else-if="showLocationStep" class="step-content">
<h3 class="step-title">Select Hit Location</h3>
<div class="location-field">
<!-- Diamond visualization for location selection -->
<div class="location-diamond">
<button
v-for="loc in HIT_LOCATIONS"
:key="loc.id"
class="location-button"
:class="getLocationClass(loc.id)"
:title="loc.position"
@click="selectLocation(loc.id)"
>
{{ loc.label }}
</button>
</div>
</div>
</div>
<!-- Cancel Button -->
<div class="action-bar">
<button
class="cancel-button"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { PlayOutcome } from '~/types/game'
import {
type OutcomeCategory,
type OnBaseSubCategory,
type OutSubCategory,
CATEGORY_CONFIG,
ON_BASE_OUTCOMES,
OUT_OUTCOMES,
X_CHECK_OUTCOMES,
HIT_LOCATIONS,
requiresHitLocation,
getOutcomeLabel,
} from '~/constants/outcomeFlow'
interface Props {
canSubmit?: boolean
}
const props = withDefaults(defineProps<Props>(), {
canSubmit: true,
})
const emit = defineEmits<{
submit: [{ outcome: PlayOutcome; hitLocation?: string }]
cancel: []
}>()
// Wizard state
const currentStep = ref(1)
const selectedCategory = ref<OutcomeCategory | null>(null)
const selectedSubCategory = ref<OnBaseSubCategory | OutSubCategory | null>(null)
const selectedOutcome = ref<PlayOutcome | null>(null)
const selectedLocation = ref<string | null>(null)
// Computed properties
const totalSteps = computed(() => {
if (!selectedCategory.value) return 3
if (selectedCategory.value === 'X_CHECK') return 2
return 4
})
const currentSubCategories = computed(() => {
if (selectedCategory.value === 'ON_BASE') return ON_BASE_OUTCOMES
if (selectedCategory.value === 'OUT') return OUT_OUTCOMES
return {}
})
const currentOutcomes = computed<PlayOutcome[]>(() => {
if (selectedCategory.value === 'X_CHECK') return X_CHECK_OUTCOMES
if (!selectedSubCategory.value) return []
if (selectedCategory.value === 'ON_BASE') {
return ON_BASE_OUTCOMES[selectedSubCategory.value as OnBaseSubCategory]?.outcomes || []
}
if (selectedCategory.value === 'OUT') {
return OUT_OUTCOMES[selectedSubCategory.value as OutSubCategory]?.outcomes || []
}
return []
})
const showLocationStep = computed(() => {
return selectedOutcome.value && requiresHitLocation(selectedOutcome.value)
})
// Methods
function selectCategory(category: OutcomeCategory) {
selectedCategory.value = category
if (category === 'X_CHECK') {
// X-Check goes directly to location selection
selectedOutcome.value = 'x_check'
currentStep.value = 2
} else {
currentStep.value = 2
}
}
function selectSubCategory(subCategory: string) {
selectedSubCategory.value = subCategory as OnBaseSubCategory | OutSubCategory
const outcomes = currentOutcomes.value
if (outcomes.length === 1) {
// Single outcome - auto-select and check if location needed
selectOutcome(outcomes[0])
} else {
currentStep.value = 3
}
}
function selectOutcome(outcome: PlayOutcome) {
selectedOutcome.value = outcome
if (requiresHitLocation(outcome)) {
currentStep.value = 4
} else {
// Submit directly
submitOutcome()
}
}
function selectLocation(location: string) {
selectedLocation.value = location
submitOutcome()
}
function submitOutcome() {
if (!selectedOutcome.value) return
if (!props.canSubmit) return
emit('submit', {
outcome: selectedOutcome.value,
hitLocation: selectedLocation.value || undefined,
})
// Reset wizard
resetWizard()
}
function goBack() {
if (currentStep.value === 4) {
// Going back from location
if (selectedCategory.value === 'X_CHECK') {
currentStep.value = 1
selectedCategory.value = null
selectedOutcome.value = null
} else if (currentOutcomes.value.length === 1) {
// Was auto-selected, go back to subcategory
currentStep.value = 2
selectedOutcome.value = null
} else {
currentStep.value = 3
}
} else if (currentStep.value === 3) {
currentStep.value = 2
selectedSubCategory.value = null
} else if (currentStep.value === 2) {
currentStep.value = 1
selectedCategory.value = null
selectedSubCategory.value = null
selectedOutcome.value = null
}
}
function handleCancel() {
resetWizard()
emit('cancel')
}
function resetWizard() {
currentStep.value = 1
selectedCategory.value = null
selectedSubCategory.value = null
selectedOutcome.value = null
selectedLocation.value = null
}
function getLocationClass(locationId: string): string {
// Position classes for diamond layout
const positionClasses: Record<string, string> = {
P: 'location-pitcher',
C: 'location-catcher',
'1B': 'location-first',
'2B': 'location-second',
SS: 'location-shortstop',
'3B': 'location-third',
LF: 'location-left',
CF: 'location-center',
RF: 'location-right',
}
return positionClasses[locationId] || ''
}
</script>
<style scoped>
.outcome-wizard {
@apply space-y-4;
}
/* Progress Bar */
.progress-bar {
@apply flex items-center justify-between mb-4;
}
.progress-steps {
@apply flex gap-1;
}
.progress-step {
@apply w-8 h-1.5 bg-gray-200 rounded-full transition-colors;
}
.progress-step.active {
@apply bg-blue-500;
}
.progress-step.current {
@apply bg-blue-600;
}
.back-button {
@apply flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-600;
@apply hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors;
}
/* Step Content */
.step-content {
@apply space-y-4;
}
.step-title {
@apply text-lg font-bold text-gray-900 text-center;
}
/* Category Grid */
.category-grid {
@apply grid grid-cols-1 gap-3;
}
@media (min-width: 640px) {
.category-grid {
@apply grid-cols-3;
}
}
.category-button {
@apply flex flex-col items-center justify-center p-6 rounded-xl border-2;
@apply transition-all duration-200 min-h-[100px];
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400;
}
.category-label {
@apply text-xl font-bold mb-1;
}
.category-description {
@apply text-sm text-gray-600;
}
/* Subcategory Grid */
.subcategory-grid {
@apply grid grid-cols-2 gap-3;
}
@media (min-width: 640px) {
.subcategory-grid {
@apply grid-cols-3;
}
}
.subcategory-button {
@apply p-4 rounded-xl border-2 border-gray-200 bg-white;
@apply font-semibold text-gray-700;
@apply hover:bg-gray-50 hover:border-gray-300;
@apply transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400;
}
/* Outcome Grid */
.outcome-grid {
@apply grid grid-cols-1 gap-2;
}
@media (min-width: 640px) {
.outcome-grid {
@apply grid-cols-2;
}
}
.outcome-button {
@apply p-3 rounded-lg border-2 border-gray-200 bg-white;
@apply text-sm font-medium text-gray-700;
@apply hover:bg-blue-50 hover:border-blue-300;
@apply transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400;
}
/* Location Field */
.location-field {
@apply flex justify-center;
}
.location-diamond {
@apply relative w-64 h-64 bg-gradient-to-br from-green-600 to-green-700 rounded-lg overflow-hidden;
}
.location-button {
@apply absolute w-10 h-10 rounded-full bg-white/90 hover:bg-white;
@apply text-xs font-bold text-gray-800;
@apply shadow-lg border-2 border-gray-300 hover:border-blue-400;
@apply transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-blue-400;
transform: translate(-50%, -50%);
}
/* Location positions */
.location-pitcher {
top: 55%;
left: 50%;
}
.location-catcher {
top: 90%;
left: 50%;
}
.location-first {
top: 50%;
left: 75%;
}
.location-second {
top: 35%;
left: 60%;
}
.location-shortstop {
top: 35%;
left: 40%;
}
.location-third {
top: 50%;
left: 25%;
}
.location-left {
top: 15%;
left: 20%;
}
.location-center {
top: 10%;
left: 50%;
}
.location-right {
top: 15%;
left: 80%;
}
/* Action Bar */
.action-bar {
@apply flex justify-center pt-4 border-t border-gray-200;
}
.cancel-button {
@apply px-6 py-2 text-sm font-medium text-gray-600;
@apply hover:text-gray-900 hover:bg-gray-100;
@apply rounded-lg transition-colors;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.step-title {
@apply text-gray-100;
}
.progress-step {
@apply bg-gray-700;
}
.progress-step.active {
@apply bg-blue-400;
}
.back-button {
@apply text-gray-400 hover:text-gray-200 hover:bg-gray-800;
}
.category-description {
@apply text-gray-400;
}
.subcategory-button {
@apply bg-gray-800 border-gray-700 text-gray-300;
@apply hover:bg-gray-700 hover:border-gray-600;
}
.outcome-button {
@apply bg-gray-800 border-gray-700 text-gray-300;
@apply hover:bg-blue-900/30 hover:border-blue-600;
}
.location-button {
@apply bg-gray-200 hover:bg-white;
}
.action-bar {
@apply border-gray-700;
}
.cancel-button {
@apply text-gray-400 hover:text-gray-200 hover:bg-gray-800;
}
}
</style>

View File

@ -0,0 +1,351 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="isOpen"
class="player-card-modal-overlay"
@click.self="close"
>
<div
ref="modalRef"
class="player-card-modal"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- Drag handle for mobile -->
<div class="drag-handle">
<div class="drag-indicator" />
</div>
<!-- Close button -->
<button
class="close-button"
@click="close"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Player card image -->
<div class="card-image-container">
<img
v-if="player?.image"
:src="player.image"
:alt="`${player.name} playing card`"
class="card-image"
@error="onImageError"
>
<div v-else class="card-placeholder">
<span class="placeholder-text">{{ playerInitials }}</span>
</div>
</div>
<!-- Player info -->
<div class="player-info">
<h2 class="player-name">{{ player?.name || 'Unknown Player' }}</h2>
<div class="player-details">
<span v-if="position" class="position-badge">{{ position }}</span>
<span v-if="teamName" class="team-name">{{ teamName }}</span>
</div>
</div>
<!-- Substitute button -->
<button
v-if="showSubstituteButton"
class="substitute-button"
@click="onSubstitute"
>
Substitute Player
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { Lineup, BenchPlayer } from '~/types/player'
interface PlayerData {
id: number
name: string
image: string
headshot?: string
}
interface Props {
isOpen: boolean
player: PlayerData | null
position?: string
teamName?: string
showSubstituteButton?: boolean
}
const props = withDefaults(defineProps<Props>(), {
position: '',
teamName: '',
showSubstituteButton: false,
})
const emit = defineEmits<{
close: []
substitute: [playerId: number]
}>()
const modalRef = ref<HTMLElement | null>(null)
const touchStartY = ref(0)
const touchCurrentY = ref(0)
const isDragging = ref(false)
// Computed
const playerInitials = computed(() => {
if (!props.player?.name) return '?'
const parts = props.player.name.split(' ')
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
}
return parts[0][0].toUpperCase()
})
// Methods
const close = () => {
emit('close')
}
const onSubstitute = () => {
if (props.player) {
emit('substitute', props.player.id)
}
}
const onImageError = (event: Event) => {
const target = event.target as HTMLImageElement
target.style.display = 'none'
}
// Touch handling for swipe-to-close
const onTouchStart = (event: TouchEvent) => {
touchStartY.value = event.touches[0].clientY
isDragging.value = true
}
const onTouchMove = (event: TouchEvent) => {
if (!isDragging.value) return
touchCurrentY.value = event.touches[0].clientY
const deltaY = touchCurrentY.value - touchStartY.value
if (deltaY > 0 && modalRef.value) {
// Only allow dragging downward
modalRef.value.style.transform = `translateY(${deltaY}px)`
}
}
const onTouchEnd = () => {
if (!isDragging.value) return
isDragging.value = false
const deltaY = touchCurrentY.value - touchStartY.value
if (deltaY > 100) {
// Swipe down threshold reached - close the modal
close()
}
// Reset position
if (modalRef.value) {
modalRef.value.style.transform = ''
}
}
// Reset touch state when modal closes
watch(() => props.isOpen, (isOpen) => {
if (!isOpen) {
touchStartY.value = 0
touchCurrentY.value = 0
isDragging.value = false
}
})
</script>
<style scoped>
.player-card-modal-overlay {
@apply fixed inset-0 z-50 flex items-end justify-center;
@apply bg-black/60 backdrop-blur-sm;
}
@media (min-width: 768px) {
.player-card-modal-overlay {
@apply items-center;
}
}
.player-card-modal {
@apply relative bg-white rounded-t-2xl w-full max-w-md;
@apply pb-6 pt-2 px-4;
@apply shadow-2xl;
@apply transition-transform duration-200;
max-height: 90vh;
overflow-y: auto;
}
@media (min-width: 768px) {
.player-card-modal {
@apply rounded-2xl;
max-height: 85vh;
}
}
/* Drag handle */
.drag-handle {
@apply flex justify-center py-2 mb-2;
}
.drag-indicator {
@apply w-10 h-1 bg-gray-300 rounded-full;
}
@media (min-width: 768px) {
.drag-handle {
@apply hidden;
}
}
/* Close button */
.close-button {
@apply absolute top-3 right-3 p-2 rounded-full;
@apply bg-gray-100 text-gray-600;
@apply hover:bg-gray-200 transition-colors;
@apply min-w-[44px] min-h-[44px] flex items-center justify-center;
}
/* Card image */
.card-image-container {
@apply flex justify-center mb-4;
}
.card-image {
@apply max-w-full max-h-[50vh] object-contain rounded-lg shadow-lg;
}
@media (min-width: 768px) {
.card-image {
max-height: 60vh;
}
}
.card-placeholder {
@apply w-48 h-64 bg-gradient-to-br from-gray-200 to-gray-300;
@apply rounded-lg flex items-center justify-center;
@apply shadow-lg;
}
.placeholder-text {
@apply text-4xl font-bold text-gray-500;
}
/* Player info */
.player-info {
@apply text-center mb-4;
}
.player-name {
@apply text-xl font-bold text-gray-900 mb-1;
}
.player-details {
@apply flex items-center justify-center gap-2 flex-wrap;
}
.position-badge {
@apply px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-semibold;
}
.team-name {
@apply text-gray-600 text-sm;
}
/* Substitute button */
.substitute-button {
@apply w-full py-4 px-6 rounded-xl;
@apply bg-gradient-to-r from-orange-500 to-orange-600 text-white;
@apply font-bold text-lg;
@apply hover:from-orange-600 hover:to-orange-700;
@apply active:scale-95 transition-all;
@apply min-h-[52px];
}
/* Transition animations */
.modal-enter-active {
@apply transition-all duration-300 ease-out;
}
.modal-leave-active {
@apply transition-all duration-200 ease-in;
}
.modal-enter-from {
@apply opacity-0;
}
.modal-enter-from .player-card-modal {
@apply translate-y-full;
}
@media (min-width: 768px) {
.modal-enter-from .player-card-modal {
@apply translate-y-0 scale-95;
}
}
.modal-leave-to {
@apply opacity-0;
}
.modal-leave-to .player-card-modal {
@apply translate-y-full;
}
@media (min-width: 768px) {
.modal-leave-to .player-card-modal {
@apply translate-y-0 scale-95;
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.player-card-modal {
@apply bg-gray-800;
}
.drag-indicator {
@apply bg-gray-600;
}
.close-button {
@apply bg-gray-700 text-gray-300 hover:bg-gray-600;
}
.card-placeholder {
@apply from-gray-700 to-gray-600;
}
.placeholder-text {
@apply text-gray-400;
}
.player-name {
@apply text-gray-100;
}
.position-badge {
@apply bg-blue-900 text-blue-200;
}
.team-name {
@apply text-gray-400;
}
}
</style>

View File

@ -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;
}
}
</style>

View File

@ -0,0 +1,311 @@
<template>
<Teleport to="body">
<!-- Backdrop (only visible when expanded) -->
<Transition name="fade">
<div
v-if="isOpen && !isMinimized"
class="bottom-sheet-backdrop"
@click="minimize"
/>
</Transition>
<!-- Bottom Sheet Container -->
<Transition name="slide">
<div
v-if="isOpen"
ref="sheetRef"
class="bottom-sheet-container"
:class="{ 'is-minimized': isMinimized }"
:style="sheetStyle"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- Drag Handle -->
<div class="drag-handle" @click="toggleMinimize">
<div class="drag-indicator" />
<span v-if="isMinimized" class="minimize-label">{{ title }} - Tap to expand</span>
</div>
<!-- Sheet Content -->
<div v-show="!isMinimized" class="sheet-content">
<slot />
</div>
</div>
</Transition>
<!-- Floating Restore Button (shown when minimized) -->
<Transition name="pop">
<button
v-if="isOpen && isMinimized && showFloatingButton"
class="floating-restore-button"
@click="expand"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
<span class="button-label">{{ title }}</span>
</button>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
interface Props {
isOpen: boolean
title?: string
showFloatingButton?: boolean
minimizeThreshold?: number
startMinimized?: boolean
}
const props = withDefaults(defineProps<Props>(), {
title: 'Panel',
showFloatingButton: true,
minimizeThreshold: 100,
startMinimized: false,
})
const emit = defineEmits<{
close: []
minimize: []
expand: []
}>()
// State
const sheetRef = ref<HTMLElement | null>(null)
const isMinimized = ref(props.startMinimized)
const touchStartY = ref(0)
const touchCurrentY = ref(0)
const isDragging = ref(false)
const dragOffset = ref(0)
// Computed style for drag animation
const sheetStyle = computed(() => {
if (isDragging.value && dragOffset.value > 0) {
return {
transform: `translateY(${dragOffset.value}px)`,
transition: 'none',
}
}
return {}
})
// Touch handlers for swipe gestures
function onTouchStart(event: TouchEvent) {
touchStartY.value = event.touches[0].clientY
isDragging.value = true
}
function onTouchMove(event: TouchEvent) {
if (!isDragging.value) return
touchCurrentY.value = event.touches[0].clientY
const deltaY = touchCurrentY.value - touchStartY.value
// Only allow dragging down (positive delta)
if (deltaY > 0 && !isMinimized.value) {
dragOffset.value = deltaY
}
// Allow dragging up when minimized
if (deltaY < 0 && isMinimized.value) {
// Subtle feedback for upward drag
dragOffset.value = Math.max(deltaY / 2, -30)
}
}
function onTouchEnd() {
if (!isDragging.value) return
isDragging.value = false
const deltaY = touchCurrentY.value - touchStartY.value
// Swipe down to minimize
if (deltaY > props.minimizeThreshold && !isMinimized.value) {
minimize()
}
// Swipe up to expand
if (deltaY < -50 && isMinimized.value) {
expand()
}
// Reset drag offset
dragOffset.value = 0
}
// Actions
function minimize() {
isMinimized.value = true
emit('minimize')
}
function expand() {
isMinimized.value = false
emit('expand')
}
function toggleMinimize() {
if (isMinimized.value) {
expand()
} else {
minimize()
}
}
// Reset state when closed
watch(() => props.isOpen, (isOpen) => {
if (!isOpen) {
isDragging.value = false
dragOffset.value = 0
touchStartY.value = 0
touchCurrentY.value = 0
} else if (props.startMinimized) {
isMinimized.value = true
}
})
// Prevent body scroll when sheet is open and expanded
watch([() => props.isOpen, isMinimized], ([open, minimized]) => {
if (open && !minimized) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>
<style scoped>
/* Backdrop */
.bottom-sheet-backdrop {
@apply fixed inset-0 z-40 bg-black/40 backdrop-blur-sm;
}
/* Container */
.bottom-sheet-container {
@apply fixed bottom-0 left-0 right-0 z-50;
@apply bg-white rounded-t-2xl shadow-2xl;
@apply max-h-[85vh] overflow-hidden;
@apply transition-all duration-300 ease-out;
}
.bottom-sheet-container.is-minimized {
@apply max-h-14;
@apply shadow-lg;
}
/* Drag Handle */
.drag-handle {
@apply flex flex-col items-center py-3 cursor-grab active:cursor-grabbing;
@apply border-b border-gray-100;
}
.drag-indicator {
@apply w-10 h-1 bg-gray-300 rounded-full;
}
.minimize-label {
@apply mt-1 text-xs font-medium text-gray-500;
}
/* Sheet Content */
.sheet-content {
@apply max-h-[calc(85vh-56px)] overflow-y-auto;
@apply p-4;
}
/* Floating Restore Button */
.floating-restore-button {
@apply fixed bottom-20 left-1/2 -translate-x-1/2 z-50;
@apply flex items-center gap-2 px-4 py-2;
@apply bg-blue-600 hover:bg-blue-700 text-white;
@apply rounded-full shadow-lg;
@apply font-medium text-sm;
@apply transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2;
}
.button-label {
@apply whitespace-nowrap;
}
/* Transitions */
.fade-enter-active,
.fade-leave-active {
@apply transition-opacity duration-300;
}
.fade-enter-from,
.fade-leave-to {
@apply opacity-0;
}
.slide-enter-active {
@apply transition-transform duration-300 ease-out;
}
.slide-leave-active {
@apply transition-transform duration-200 ease-in;
}
.slide-enter-from,
.slide-leave-to {
@apply translate-y-full;
}
.pop-enter-active {
@apply transition-all duration-300 ease-out;
}
.pop-leave-active {
@apply transition-all duration-200 ease-in;
}
.pop-enter-from,
.pop-leave-to {
@apply opacity-0 scale-75 translate-y-4;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.bottom-sheet-container {
@apply bg-gray-800;
}
.drag-handle {
@apply border-gray-700;
}
.drag-indicator {
@apply bg-gray-600;
}
.minimize-label {
@apply text-gray-400;
}
}
/* Desktop adjustments */
@media (min-width: 768px) {
.bottom-sheet-container {
@apply max-w-lg left-1/2 -translate-x-1/2 right-auto;
@apply max-h-[70vh];
}
.bottom-sheet-container.is-minimized {
@apply translate-x-0 left-auto right-4 rounded-t-xl;
@apply max-w-xs;
}
.floating-restore-button {
@apply bottom-6 right-6 left-auto translate-x-0;
}
}
</style>

View File

@ -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<OutcomeCategory, CategoryConfig> = {
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<OnBaseSubCategory, SubCategoryConfig> = {
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<OutSubCategory, SubCategoryConfig> = {
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<Record<PlayOutcome, string>> = {
// 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
}

View File

@ -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])