Merge pull request 'feature/gameplay-ui-improvements' (#2) from feature/gameplay-ui-improvements into main
Reviewed-on: #2
This commit is contained in:
commit
9ba611bfee
204
docs/runner-display-alternatives.md
Normal file
204
docs/runner-display-alternatives.md
Normal file
@ -0,0 +1,204 @@
|
||||
# Runner Display Alternatives - Design Proposals
|
||||
|
||||
## Current Implementation Issues
|
||||
- Baseball diamond is visually cluttered
|
||||
- Runners are represented by small numbered circles that are hard to see
|
||||
- Player information is not immediately accessible
|
||||
- Takes up significant vertical space
|
||||
- Doesn't provide quick access to runner card details
|
||||
- UX is confusing - users want to quickly glance at cards for each runner
|
||||
|
||||
## Design Goals
|
||||
- **Glanceable**: See runner status instantly without searching
|
||||
- **Compact**: Use less vertical space (mobile-first)
|
||||
- **Card Access**: Quick access to view runner player cards
|
||||
- **Clear State**: Immediately obvious which bases are occupied
|
||||
- **Professional**: Modern, polished appearance
|
||||
|
||||
---
|
||||
|
||||
## Option 1: Horizontal Runner Card Bar
|
||||
|
||||
### Visual Description
|
||||
A sleek horizontal bar showing 3 slots (1st, 2nd, 3rd) with mini player cards that expand on tap/click.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ RUNNERS ON BASE │
|
||||
│ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||
│ │ 1B │ │ 2B │ │ 3B │ │
|
||||
│ │ ──── │ │[IMG] │ │ ──── │ │
|
||||
│ │ │ │ J.D. │ │ │ │
|
||||
│ │ Empty│ │ │ │ Empty│ │
|
||||
│ └──────┘ └──────┘ └──────┘ │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Features
|
||||
- **Empty State**: Gray/muted card with dashed border and "Empty" text
|
||||
- **Occupied State**: Player headshot, name, and team color border
|
||||
- **Tap to Expand**: Shows full player card modal
|
||||
- **Visual Hierarchy**: Occupied bases use team colors and are visually prominent
|
||||
- **Badge Indicator**: Small circular badge with base number (1/2/3) on top-right
|
||||
|
||||
### Pros
|
||||
- ✅ Very compact (single row)
|
||||
- ✅ Clear visual distinction between empty/occupied
|
||||
- ✅ Easy touch targets for mobile
|
||||
- ✅ Horizontally balanced layout
|
||||
- ✅ Player images make it personal/engaging
|
||||
|
||||
### Cons
|
||||
- ❌ May be cramped on very small screens
|
||||
- ❌ Requires good player headshots for best effect
|
||||
|
||||
### CSS Approach
|
||||
- Grid layout with 3 equal columns
|
||||
- Card-style mini containers
|
||||
- Smooth hover/tap states with scale transform
|
||||
- Team color accent on occupied cards
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Compact Diamond Indicator Strip
|
||||
|
||||
### Visual Description
|
||||
Minimalist strip showing bases as dots/indicators with runner names inline.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ ○ 1B Empty ● 2B J. Doe (#24) ○ 3B Empty │
|
||||
│ Tap any runner to view card │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Features
|
||||
- **Dot Indicators**: Hollow circle = empty, filled circle = occupied
|
||||
- **Inline Text**: Base label + player name (abbreviated) + jersey number
|
||||
- **Color Coding**: Dot and text use team color when occupied
|
||||
- **Single Line**: Extremely compact
|
||||
- **Hover State**: Underline runner names, show "tap to view card" tooltip
|
||||
|
||||
### Pros
|
||||
- ✅ Absolute minimal space usage
|
||||
- ✅ Text-based, works without images
|
||||
- ✅ Extremely fast to scan
|
||||
- ✅ Works well in both light/dark mode
|
||||
|
||||
### Cons
|
||||
- ❌ Less visually engaging
|
||||
- ❌ No player images visible
|
||||
- ❌ May feel too minimal/plain
|
||||
- ❌ Harder to tap on mobile (smaller targets)
|
||||
|
||||
### CSS Approach
|
||||
- Flexbox with space-between
|
||||
- Dot using ::before pseudo-element
|
||||
- Text truncation for long names
|
||||
- Underline decoration on hover/active
|
||||
|
||||
---
|
||||
|
||||
## Option 3: Stacked Runner Cards (Recommended)
|
||||
|
||||
### Visual Description
|
||||
Card-based interface showing only occupied bases as expandable cards in a horizontal stack. Empty bases are represented by small placeholder chips.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ RUNNERS ON BASE │
|
||||
│ │
|
||||
│ 1B: ─ 2B: [Card] 3B: ─ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ [IMG] │ │
|
||||
│ │ J. Doe │ │
|
||||
│ │ #24 2B │ │
|
||||
│ │ [View] │ │
|
||||
│ └──────────┘ │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Features
|
||||
- **Empty Bases**: Small chip/badge showing "1B: —" (muted, minimal)
|
||||
- **Occupied Bases**: Full mini card with:
|
||||
- Player headshot (circular)
|
||||
- Player name (bold)
|
||||
- Jersey number + position
|
||||
- Team color accent/border
|
||||
- "View Card" button
|
||||
- **Responsive**: Cards stack vertically on narrow screens
|
||||
- **Visual Priority**: Occupied bases are much larger and more prominent
|
||||
- **Quick Actions**: Button directly on card for instant access
|
||||
|
||||
### Pros
|
||||
- ✅ Best balance of compact + informative
|
||||
- ✅ Only shows detail where needed (occupied bases)
|
||||
- ✅ Clear call-to-action with "View Card" button
|
||||
- ✅ Player images add personality
|
||||
- ✅ Responsive layout adapts well to mobile
|
||||
- ✅ Professional, modern card-based UI pattern
|
||||
|
||||
### Cons
|
||||
- ❌ Slightly more vertical space when bases loaded
|
||||
- ❌ Requires player headshots for best experience
|
||||
|
||||
### CSS Approach
|
||||
- Flexbox/Grid hybrid (flex-wrap for responsive)
|
||||
- Empty bases: inline-flex badge (h-8, minimal)
|
||||
- Occupied bases: w-32 card with padding
|
||||
- Shadow and border-radius for card depth
|
||||
- Team color as left border accent (4px)
|
||||
|
||||
### Implementation Details
|
||||
```vue
|
||||
<div class="runners-container">
|
||||
<!-- Empty Base -->
|
||||
<div class="runner-empty">
|
||||
<span class="base-label">1B:</span>
|
||||
<span class="empty-indicator">—</span>
|
||||
</div>
|
||||
|
||||
<!-- Occupied Base -->
|
||||
<div class="runner-card" :style="{ borderColor: teamColor }">
|
||||
<img :src="player.headshot" class="runner-avatar" />
|
||||
<div class="runner-info">
|
||||
<p class="runner-name">{{ player.name }}</p>
|
||||
<p class="runner-meta">#{{ player.jersey }} • 2B</p>
|
||||
</div>
|
||||
<button @click="viewCard(player)" class="btn-view-card">
|
||||
View Card
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommendation: Option 3 (Stacked Runner Cards)
|
||||
|
||||
### Why This Is Best
|
||||
1. **Optimizes for the common case**: Most at-bats have 0-1 runners, so minimizing empty state is key
|
||||
2. **Mobile-first**: Large tap targets, clear hierarchy, easy to use one-handed
|
||||
3. **Information density**: Shows exactly what's needed without clutter
|
||||
4. **Modern UX**: Card-based pattern is familiar and professional
|
||||
5. **Scalable**: Works equally well with 1, 2, or 3 runners on base
|
||||
|
||||
### Responsive Behavior
|
||||
- **Mobile (< 640px)**: Stack cards vertically, full width
|
||||
- **Tablet (640-1024px)**: Horizontal row, cards side-by-side
|
||||
- **Desktop (> 1024px)**: Same as tablet, more breathing room
|
||||
|
||||
### Accessibility
|
||||
- Semantic HTML with proper ARIA labels
|
||||
- Keyboard navigation support
|
||||
- High contrast for empty vs occupied states
|
||||
- Screen reader announces "Runner on second base: J. Doe, number 24"
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
1. Choose preferred option (or hybrid approach)
|
||||
2. Create Vue component implementation
|
||||
3. Test on mobile devices
|
||||
4. Gather user feedback
|
||||
5. Iterate based on real gameplay usage
|
||||
625
docs/runner-display-mockups.html
Normal file
625
docs/runner-display-mockups.html
Normal file
@ -0,0 +1,625 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Runner Display Mockups - Options 1, 3, & 4</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* Option 1 Styles */
|
||||
.runner-card-option1 {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.runner-card-option1:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.runner-card-option1.occupied {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Option 3 Styles */
|
||||
.runner-card-option3 {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.runner-card-option3:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* Option 4 Styles */
|
||||
.runner-card-option4 {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.runner-card-option4.empty {
|
||||
cursor: default;
|
||||
}
|
||||
.runner-card-option4.occupied {
|
||||
cursor: pointer;
|
||||
}
|
||||
.runner-card-option4.occupied:hover:not(.expanded) {
|
||||
transform: translateX(4px);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* Expanded state - runner card grows */
|
||||
.runner-card-option4.expanded {
|
||||
transform: scale(1.02);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Pulse animation for occupied bases */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(59, 130, 246, 0); }
|
||||
}
|
||||
|
||||
.pulse-occupied {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Slide-in animation for Option 4 */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-in {
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Matchup card styles */
|
||||
.matchup-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes pulseGlowBlue {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 15px 2px rgba(59, 130, 246, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 30px 8px rgba(59, 130, 246, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseGlowGreen {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 15px 2px rgba(16, 185, 129, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 30px 8px rgba(16, 185, 129, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Expand animation */
|
||||
.expand-height {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Card image fade in */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 p-8">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-2">Runner Display Mockups</h1>
|
||||
<p class="text-gray-600">Interactive mockups of Options 1, 3, and 4 for runners on base display</p>
|
||||
</div>
|
||||
|
||||
<!-- Toggle Scenarios -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-md p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Test Scenarios</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button onclick="setScenario('empty')" class="px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg font-medium transition">
|
||||
All Empty
|
||||
</button>
|
||||
<button onclick="setScenario('single')" class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition">
|
||||
Runner on 2B
|
||||
</button>
|
||||
<button onclick="setScenario('double')" class="px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg font-medium transition">
|
||||
Runners on 1B & 3B
|
||||
</button>
|
||||
<button onclick="setScenario('loaded')" class="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg font-medium transition">
|
||||
Bases Loaded
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OPTION 1: Horizontal Runner Card Bar -->
|
||||
<section class="mb-12 bg-white rounded-xl shadow-lg p-8">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">Option 1: Horizontal Runner Card Bar</h2>
|
||||
<p class="text-gray-600">Three equal-width cards showing all bases with equal visual weight</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-b from-gray-50 to-gray-100 rounded-lg p-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 uppercase tracking-wide">Runners on Base</h3>
|
||||
|
||||
<!-- Desktop View -->
|
||||
<div class="hidden md:grid md:grid-cols-3 gap-4" id="option1-desktop">
|
||||
<!-- Cards will be inserted here -->
|
||||
</div>
|
||||
|
||||
<!-- Mobile View -->
|
||||
<div class="md:hidden space-y-3" id="option1-mobile">
|
||||
<!-- Cards will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pros/Cons -->
|
||||
<div class="mt-6 grid md:grid-cols-2 gap-4">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-green-800 mb-2">✅ Pros</h4>
|
||||
<ul class="text-sm text-green-700 space-y-1">
|
||||
<li>• Very compact (single row)</li>
|
||||
<li>• Clear visual distinction</li>
|
||||
<li>• Easy touch targets</li>
|
||||
<li>• Balanced layout</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-red-800 mb-2">❌ Cons</h4>
|
||||
<ul class="text-sm text-red-700 space-y-1">
|
||||
<li>• Always shows all 3 cards</li>
|
||||
<li>• May feel cluttered when bases empty</li>
|
||||
<li>• Less visual hierarchy</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- OPTION 3: Stacked Runner Cards -->
|
||||
<section class="mb-12 bg-white rounded-xl shadow-lg p-8">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">Option 3: Stacked Runner Cards</h2>
|
||||
<p class="text-gray-600">Only occupied bases get prominent cards, empty bases are minimal chips</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-b from-blue-50 to-blue-100 rounded-lg p-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 uppercase tracking-wide">Runners on Base</h3>
|
||||
|
||||
<div class="flex flex-wrap gap-3" id="option3-container">
|
||||
<!-- Cards will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pros/Cons -->
|
||||
<div class="mt-6 grid md:grid-cols-2 gap-4">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-green-800 mb-2">✅ Pros</h4>
|
||||
<ul class="text-sm text-green-700 space-y-1">
|
||||
<li>• Optimizes for common case (0-1 runners)</li>
|
||||
<li>• Clear visual hierarchy</li>
|
||||
<li>• Easy card access with button</li>
|
||||
<li>• Mobile-first design</li>
|
||||
<li>• Professional card-based UI</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-red-808 mb-2">❌ Cons</h4>
|
||||
<ul class="text-sm text-red-700 space-y-1">
|
||||
<li>• More horizontal space with 3 runners</li>
|
||||
<li>• Requires player headshots</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- OPTION 4: Expanding Runner Cards with Matchup (NEW - RECOMMENDED) -->
|
||||
<section class="mb-12 bg-gradient-to-br from-purple-50 to-indigo-50 rounded-xl shadow-lg p-8 border-2 border-purple-200">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">
|
||||
Option 4: Expanding Runner Cards with Matchup
|
||||
<span class="ml-2 text-sm bg-purple-600 text-white px-3 py-1 rounded-full">NEW RECOMMENDATION</span>
|
||||
</h2>
|
||||
<p class="text-gray-600">
|
||||
Runners list on left, catcher on right. <strong>Click a runner to expand it in place</strong> and show full card + catcher matchup.
|
||||
<strong>Only visible when runners on base.</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Container with split layout -->
|
||||
<div id="option4-container" class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<!-- Split Layout: Runners List | Catcher Card -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-0">
|
||||
<!-- LEFT: Runners List (with expandable cards) -->
|
||||
<div class="border-r border-gray-200 p-4">
|
||||
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">Runners on Base</h3>
|
||||
|
||||
<div id="option4-list" class="space-y-2">
|
||||
<!-- Runner cards will be inserted here -->
|
||||
</div>
|
||||
|
||||
<!-- Hidden state message -->
|
||||
<div id="option4-hidden" class="hidden text-center py-6 text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p class="text-sm">No runners on base</p>
|
||||
<p class="text-xs text-gray-400 mt-1">Component hidden</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Catcher Card -->
|
||||
<div class="p-4 bg-gray-50" id="option4-catcher-container">
|
||||
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">Catcher</h3>
|
||||
|
||||
<!-- Collapsed state - minimal card -->
|
||||
<div id="catcher-collapsed" class="bg-white border-l-4 border-gray-600 rounded-lg p-3 shadow-sm transition-all duration-300">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-gray-600 flex-shrink-0">
|
||||
<img src="https://via.placeholder.com/80/6b7280/ffffff?text=C" class="w-full h-full object-cover">
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-bold text-gray-900">Buster Posey</div>
|
||||
<div class="text-xs text-gray-600">#28 • C</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-xs text-gray-500 text-center">
|
||||
Click a runner to see matchup →
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded state - full card (hidden by default) -->
|
||||
<div id="catcher-expanded" class="hidden matchup-card bg-gradient-to-b from-green-900 to-green-950 border-2 border-green-600 rounded-xl overflow-hidden shadow-lg fade-in" style="animation: pulseGlowGreen 2s ease-in-out infinite;">
|
||||
<!-- Card Header -->
|
||||
<div class="bg-green-800/80 px-3 py-2 flex items-center gap-2 text-white text-sm font-semibold">
|
||||
<span class="font-bold text-white/90">CATCHER</span>
|
||||
<span class="text-white/70">C</span>
|
||||
<span class="truncate flex-1 text-right font-bold">Buster Posey</span>
|
||||
</div>
|
||||
<!-- Card Image -->
|
||||
<div class="p-0">
|
||||
<img src="https://via.placeholder.com/400x550/6b7280/ffffff?text=Catcher+Card" class="w-full h-auto">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pros/Cons -->
|
||||
<div class="mt-6 grid md:grid-cols-2 gap-4">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-green-800 mb-2">✅ Pros</h4>
|
||||
<ul class="text-sm text-green-700 space-y-1">
|
||||
<li>• <strong>Expands in place</strong> - no jarring screen flip</li>
|
||||
<li>• <strong>Smooth transition</strong> - runner card grows to show full player card</li>
|
||||
<li>• Catcher card appears simultaneously for matchup context</li>
|
||||
<li>• Only shows when needed (on_base_code > 0)</li>
|
||||
<li>• Consistent with pitcher vs batter styling</li>
|
||||
<li>• Clear visual hierarchy</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-red-800 mb-2">❌ Cons</h4>
|
||||
<ul class="text-sm text-red-700 space-y-1">
|
||||
<li>• Requires extra click to see full cards</li>
|
||||
<li>• Slightly more complex animation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Implementation Notes -->
|
||||
<div class="mt-6 bg-purple-100 border border-purple-300 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-purple-900 mb-2">💡 Implementation Notes</h4>
|
||||
<ul class="text-sm text-purple-800 space-y-1">
|
||||
<li>• Component only renders when <code class="bg-purple-200 px-1 rounded">thisPlay.on_base_code > 0</code></li>
|
||||
<li>• Default: Runners list (left) with compact cards + Catcher summary (right)</li>
|
||||
<li>• Click runner → Runner card expands in place to show full player card</li>
|
||||
<li>• Simultaneously, catcher card expands to show full matchup</li>
|
||||
<li>• Click again or click another runner to collapse/switch</li>
|
||||
<li>• Smooth height transition using CSS max-height animation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Comparison -->
|
||||
<section class="bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-xl shadow-lg p-8">
|
||||
<h2 class="text-2xl font-bold mb-4">Final Recommendation: Option 4</h2>
|
||||
<div class="space-y-2 text-lg">
|
||||
<p>🎯 <strong>Smooth in-place expansion</strong> - Runner card grows to reveal full player card</p>
|
||||
<p>🔄 <strong>Integrated matchup view</strong> - Catcher card appears alongside for comparison</p>
|
||||
<p>👁️ <strong>No screen flipping</strong> - Everything expands/collapses in the same layout</p>
|
||||
<p>📱 <strong>Mobile-first design</strong> - Works seamlessly on all screen sizes</p>
|
||||
<p>⚾ <strong>Strategic context</strong> - See runner vs catcher for steal decisions</p>
|
||||
<p>✨ <strong>Clean UX</strong> - Compact by default, detailed on demand</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Sample player data
|
||||
const players = {
|
||||
first: { name: 'Mike Trout', number: '27', team: 'blue', headshot: 'https://via.placeholder.com/80/3b82f6/ffffff?text=MT' },
|
||||
second: { name: 'Aaron Judge', number: '99', team: 'red', headshot: 'https://via.placeholder.com/80/ef4444/ffffff?text=AJ' },
|
||||
third: { name: 'Shohei Ohtani', number: '17', team: 'green', headshot: 'https://via.placeholder.com/80/10b981/ffffff?text=SO' }
|
||||
};
|
||||
|
||||
const teamColors = {
|
||||
blue: { border: '#3b82f6', bg: '#dbeafe' },
|
||||
red: { border: '#ef4444', bg: '#fee2e2' },
|
||||
green: { border: '#10b981', bg: '#d1fae5' }
|
||||
};
|
||||
|
||||
let currentScenario = {
|
||||
first: false,
|
||||
second: false,
|
||||
third: false
|
||||
};
|
||||
|
||||
let selectedRunner = null;
|
||||
|
||||
function setScenario(type) {
|
||||
switch(type) {
|
||||
case 'empty':
|
||||
currentScenario = { first: false, second: false, third: false };
|
||||
break;
|
||||
case 'single':
|
||||
currentScenario = { first: false, second: true, third: false };
|
||||
break;
|
||||
case 'double':
|
||||
currentScenario = { first: true, second: false, third: true };
|
||||
break;
|
||||
case 'loaded':
|
||||
currentScenario = { first: true, second: true, third: true };
|
||||
break;
|
||||
}
|
||||
selectedRunner = null;
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function selectRunner(base) {
|
||||
if (!currentScenario[base]) return; // Can't select empty base
|
||||
|
||||
// Toggle selection
|
||||
if (selectedRunner === base) {
|
||||
selectedRunner = null;
|
||||
} else {
|
||||
selectedRunner = base;
|
||||
}
|
||||
|
||||
updateOption4();
|
||||
}
|
||||
|
||||
function updateDisplay() {
|
||||
updateOption1();
|
||||
updateOption3();
|
||||
updateOption4();
|
||||
}
|
||||
|
||||
function updateOption1() {
|
||||
const desktopContainer = document.getElementById('option1-desktop');
|
||||
const mobileContainer = document.getElementById('option1-mobile');
|
||||
|
||||
desktopContainer.innerHTML = '';
|
||||
mobileContainer.innerHTML = '';
|
||||
|
||||
['first', 'second', 'third'].forEach(base => {
|
||||
const baseLabel = base === 'first' ? '1ST' : base === 'second' ? '2ND' : '3RD';
|
||||
const player = players[base];
|
||||
const color = teamColors[player.team];
|
||||
|
||||
// Desktop card
|
||||
const desktopCard = document.createElement('div');
|
||||
if (currentScenario[base]) {
|
||||
desktopCard.className = 'runner-card-option1 occupied bg-white rounded-lg border-2 p-4 text-center pulse-occupied';
|
||||
desktopCard.style.borderColor = color.border;
|
||||
desktopCard.style.backgroundColor = color.bg;
|
||||
desktopCard.innerHTML = `
|
||||
<div class="text-xs font-bold mb-2" style="color: ${color.border}">${baseLabel} BASE</div>
|
||||
<div class="relative w-16 h-16 mx-auto mb-2">
|
||||
<img src="${player.headshot}" class="w-full h-full rounded-full border-2 object-cover" style="border-color: ${color.border}">
|
||||
<div class="absolute -top-1 -right-1 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white" style="background: ${color.border}">
|
||||
${base === 'first' ? '1' : base === 'second' ? '2' : '3'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm font-bold text-gray-900">${player.name}</div>
|
||||
<div class="text-xs text-gray-600">#${player.number}</div>
|
||||
`;
|
||||
} else {
|
||||
desktopCard.className = 'runner-card-option1 bg-white rounded-lg border-2 border-gray-200 p-4 text-center';
|
||||
desktopCard.innerHTML = `
|
||||
<div class="text-xs font-bold text-gray-500 mb-2">${baseLabel} BASE</div>
|
||||
<div class="w-16 h-16 mx-auto mb-2 rounded-full bg-gray-100 border-2 border-dashed border-gray-300 flex items-center justify-center">
|
||||
<span class="text-gray-400 text-2xl">—</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 font-medium">Empty</div>
|
||||
`;
|
||||
}
|
||||
desktopContainer.appendChild(desktopCard);
|
||||
|
||||
// Mobile card
|
||||
const mobileCard = document.createElement('div');
|
||||
if (currentScenario[base]) {
|
||||
mobileCard.className = 'runner-card-option1 occupied bg-white rounded-lg border-2 p-3 flex items-center';
|
||||
mobileCard.style.borderColor = color.border;
|
||||
mobileCard.style.backgroundColor = color.bg;
|
||||
mobileCard.innerHTML = `
|
||||
<div class="text-xs font-bold w-12" style="color: ${color.border}">${baseLabel}</div>
|
||||
<img src="${player.headshot}" class="w-12 h-12 rounded-full border-2 object-cover" style="border-color: ${color.border}">
|
||||
<div class="ml-3 flex-1">
|
||||
<div class="text-sm font-bold text-gray-900">${player.name}</div>
|
||||
<div class="text-xs text-gray-600">#${player.number}</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
mobileCard.className = 'runner-card-option1 bg-white rounded-lg border-2 border-gray-200 p-3 flex items-center';
|
||||
mobileCard.innerHTML = `
|
||||
<div class="text-xs font-bold text-gray-500 w-12">${baseLabel}</div>
|
||||
<div class="w-12 h-12 rounded-full bg-gray-100 border-2 border-dashed border-gray-300 flex items-center justify-center">
|
||||
<span class="text-gray-400 text-xl">—</span>
|
||||
</div>
|
||||
<div class="ml-3 text-sm text-gray-400 font-medium">Empty</div>
|
||||
`;
|
||||
}
|
||||
mobileContainer.appendChild(mobileCard);
|
||||
});
|
||||
}
|
||||
|
||||
function updateOption3() {
|
||||
const container = document.getElementById('option3-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
['first', 'second', 'third'].forEach(base => {
|
||||
const baseLabel = base === 'first' ? '1B' : base === 'second' ? '2B' : '3B';
|
||||
|
||||
if (currentScenario[base]) {
|
||||
const player = players[base];
|
||||
const color = teamColors[player.team];
|
||||
const card = document.createElement('div');
|
||||
card.className = 'runner-card-option3 bg-white rounded-lg shadow-md p-4 cursor-pointer';
|
||||
card.style.borderLeft = `4px solid ${color.border}`;
|
||||
card.style.minWidth = '160px';
|
||||
card.innerHTML = `
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<div class="relative flex-shrink-0">
|
||||
<img src="${player.headshot}" class="w-14 h-14 rounded-full border-2 object-cover" style="border-color: ${color.border}">
|
||||
<div class="absolute -bottom-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold text-white" style="background: ${color.border}">
|
||||
${base === 'first' ? '1' : base === 'second' ? '2' : '3'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-bold text-gray-900 truncate">${player.name}</div>
|
||||
<div class="text-xs text-gray-600">#${player.number} • ${baseLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-full px-3 py-1.5 text-xs font-semibold rounded-md transition text-white hover:opacity-90" style="background: ${color.border}">
|
||||
View Card
|
||||
</button>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
} else {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'inline-flex items-center px-3 py-1.5 bg-gray-200 text-gray-500 rounded-full text-sm font-medium';
|
||||
chip.innerHTML = `
|
||||
<span class="font-bold mr-1">${baseLabel}:</span>
|
||||
<span>—</span>
|
||||
`;
|
||||
container.appendChild(chip);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateOption4() {
|
||||
const hasRunners = currentScenario.first || currentScenario.second || currentScenario.third;
|
||||
const listEl = document.getElementById('option4-list');
|
||||
const hiddenEl = document.getElementById('option4-hidden');
|
||||
const catcherCollapsed = document.getElementById('catcher-collapsed');
|
||||
const catcherExpanded = document.getElementById('catcher-expanded');
|
||||
|
||||
if (!hasRunners) {
|
||||
listEl.classList.add('hidden');
|
||||
hiddenEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.classList.remove('hidden');
|
||||
hiddenEl.classList.add('hidden');
|
||||
|
||||
// Clear and rebuild runner list
|
||||
listEl.innerHTML = '';
|
||||
|
||||
['first', 'second', 'third'].forEach(base => {
|
||||
const baseLabel = base === 'first' ? '1B' : base === 'second' ? '2B' : '3B';
|
||||
const isExpanded = selectedRunner === base;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.onclick = () => selectRunner(base);
|
||||
|
||||
if (currentScenario[base]) {
|
||||
const player = players[base];
|
||||
const color = teamColors[player.team];
|
||||
|
||||
card.className = `runner-card-option4 occupied bg-white border-l-4 rounded-lg shadow-sm slide-in ${isExpanded ? 'expanded' : ''}`;
|
||||
card.style.borderColor = color.border;
|
||||
|
||||
// Collapsed state (summary)
|
||||
const summaryHTML = `
|
||||
<div class="p-2 flex items-center">
|
||||
<div class="w-10 h-10 rounded-full flex-shrink-0 overflow-hidden border-2" style="border-color: ${color.border}">
|
||||
<img src="${player.headshot}" class="w-full h-full object-cover">
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<div class="text-sm font-bold text-gray-900">${player.name}</div>
|
||||
<div class="text-xs text-gray-600">#${player.number} • ${baseLabel}</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Expanded state (full card)
|
||||
const expandedHTML = isExpanded ? `
|
||||
<div class="expand-height overflow-hidden fade-in" style="max-height: 800px;">
|
||||
<div class="bg-gradient-to-b from-blue-900 to-blue-950 rounded-b-lg overflow-hidden" style="animation: pulseGlowBlue 2s ease-in-out infinite;">
|
||||
<div class="bg-blue-800/80 px-3 py-2 flex items-center gap-2 text-white text-xs font-semibold">
|
||||
<span class="font-bold text-white/90">RUNNER</span>
|
||||
<span class="text-white/70">${baseLabel}</span>
|
||||
<span class="truncate flex-1 text-right font-bold">${player.name}</span>
|
||||
</div>
|
||||
<div class="p-0">
|
||||
<img src="https://via.placeholder.com/400x550/${color.border.substring(1)}/ffffff?text=${player.name.replace(' ', '+')}" class="w-full h-auto">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
card.innerHTML = summaryHTML + expandedHTML;
|
||||
} else {
|
||||
card.className = 'runner-card-option4 empty bg-gray-50 border-l-4 border-gray-300 rounded-lg p-2 flex items-center';
|
||||
card.innerHTML = `
|
||||
<div class="w-10 h-10 rounded-full bg-gray-200 border-2 border-dashed border-gray-400 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-gray-400 text-sm font-bold">${baseLabel}</span>
|
||||
</div>
|
||||
<div class="ml-3 text-sm text-gray-400 font-medium">Empty</div>
|
||||
`;
|
||||
}
|
||||
|
||||
listEl.appendChild(card);
|
||||
});
|
||||
|
||||
// Toggle catcher card state
|
||||
if (selectedRunner && currentScenario[selectedRunner]) {
|
||||
catcherCollapsed.classList.add('hidden');
|
||||
catcherExpanded.classList.remove('hidden');
|
||||
} else {
|
||||
catcherCollapsed.classList.remove('hidden');
|
||||
catcherExpanded.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Add click handlers for Options 1 & 3
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.runner-card-option1.occupied') ||
|
||||
e.target.closest('.runner-card-option3')) {
|
||||
alert('In the real app, this would open the player card modal!');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
setScenario('single');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,40 +1,8 @@
|
||||
<template>
|
||||
<div class="current-situation">
|
||||
<!-- Side-by-Side Card Layout -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Current Pitcher Card -->
|
||||
<button
|
||||
v-if="currentPitcher"
|
||||
:class="[
|
||||
'player-card pitcher-card card-transition',
|
||||
pitcherCardClasses
|
||||
]"
|
||||
@click="openPlayerCard('pitcher')"
|
||||
>
|
||||
<!-- Card Header -->
|
||||
<div class="card-header pitcher-header">
|
||||
<span class="team-abbrev">{{ pitcherTeamAbbrev }}</span>
|
||||
<span class="position-info">P</span>
|
||||
<span class="player-name">{{ pitcherName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Card Image -->
|
||||
<div class="card-image-container">
|
||||
<img
|
||||
v-if="pitcherPlayer?.image"
|
||||
:src="pitcherPlayer.image"
|
||||
:alt="`${pitcherName} card`"
|
||||
class="card-image"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<div v-else class="card-placeholder pitcher-placeholder">
|
||||
<span class="placeholder-initials">{{ getPlayerFallbackInitial(pitcherPlayer) }}</span>
|
||||
<span class="placeholder-label">No Card Image</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Current Batter Card -->
|
||||
<!-- Side-by-Side Card Layout (stacked on mobile) -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Current Batter Card (LEFT on desktop, TOP on mobile) -->
|
||||
<button
|
||||
v-if="currentBatter"
|
||||
:class="[
|
||||
@ -65,6 +33,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Current Pitcher Card (RIGHT on desktop, BOTTOM on mobile) -->
|
||||
<button
|
||||
v-if="currentPitcher"
|
||||
:class="[
|
||||
'player-card pitcher-card card-transition',
|
||||
pitcherCardClasses
|
||||
]"
|
||||
@click="openPlayerCard('pitcher')"
|
||||
>
|
||||
<!-- Card Header -->
|
||||
<div class="card-header pitcher-header">
|
||||
<span class="team-abbrev">{{ pitcherTeamAbbrev }}</span>
|
||||
<span class="position-info">P</span>
|
||||
<span class="player-name">{{ pitcherName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Card Image -->
|
||||
<div class="card-image-container">
|
||||
<img
|
||||
v-if="pitcherPlayer?.image"
|
||||
:src="pitcherPlayer.image"
|
||||
:alt="`${pitcherName} card`"
|
||||
class="card-image"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<div v-else class="card-placeholder pitcher-placeholder">
|
||||
<span class="placeholder-initials">{{ getPlayerFallbackInitial(pitcherPlayer) }}</span>
|
||||
<span class="placeholder-label">No Card Image</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
|
||||
@ -62,15 +62,7 @@
|
||||
|
||||
<!-- Mobile Layout (Stacked) -->
|
||||
<div class="lg:hidden space-y-6">
|
||||
<!-- Game Board -->
|
||||
<GameBoard
|
||||
:runners="runnersState"
|
||||
:current-batter="gameState?.current_batter"
|
||||
:current-pitcher="gameState?.current_pitcher"
|
||||
:fielding-lineup="fieldingLineup"
|
||||
/>
|
||||
|
||||
<!-- Current Situation (below diamond, above gameplay panel) -->
|
||||
<!-- Current Situation -->
|
||||
<CurrentSituation
|
||||
:current-batter="gameState?.current_batter"
|
||||
:current-pitcher="gameState?.current_pitcher"
|
||||
@ -79,6 +71,16 @@
|
||||
:pitcher-team-abbrev="pitcherTeamAbbrev"
|
||||
/>
|
||||
|
||||
<!-- Runners on Base (with expandable cards) -->
|
||||
<RunnersOnBase
|
||||
:runners="runnersData"
|
||||
:fielding-lineup="fieldingLineup"
|
||||
:batting-team-color="battingTeamColor"
|
||||
:fielding-team-color="fieldingTeamColor"
|
||||
:batting-team-abbrev="batterTeamAbbrev"
|
||||
:fielding-team-abbrev="pitcherTeamAbbrev"
|
||||
/>
|
||||
|
||||
<!-- Decision Panel (Phase F3) -->
|
||||
<DecisionPanel
|
||||
v-if="showDecisions"
|
||||
@ -127,17 +129,7 @@
|
||||
<div class="hidden lg:grid lg:grid-cols-3 gap-6">
|
||||
<!-- Left Column: Game State -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Game Board -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg">
|
||||
<GameBoard
|
||||
:runners="runnersState"
|
||||
:current-batter="gameState?.current_batter"
|
||||
:current-pitcher="gameState?.current_pitcher"
|
||||
:fielding-lineup="fieldingLineup"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Current Situation (below diamond, above gameplay panel) -->
|
||||
<!-- Current Situation -->
|
||||
<CurrentSituation
|
||||
:current-batter="gameState?.current_batter"
|
||||
:current-pitcher="gameState?.current_pitcher"
|
||||
@ -146,6 +138,16 @@
|
||||
:pitcher-team-abbrev="pitcherTeamAbbrev"
|
||||
/>
|
||||
|
||||
<!-- Runners on Base (with expandable cards) -->
|
||||
<RunnersOnBase
|
||||
:runners="runnersData"
|
||||
:fielding-lineup="fieldingLineup"
|
||||
:batting-team-color="battingTeamColor"
|
||||
:fielding-team-color="fieldingTeamColor"
|
||||
:batting-team-abbrev="batterTeamAbbrev"
|
||||
:fielding-team-abbrev="pitcherTeamAbbrev"
|
||||
/>
|
||||
|
||||
<!-- Decision Panel (Phase F3) -->
|
||||
<DecisionPanel
|
||||
v-if="showDecisions"
|
||||
@ -326,8 +328,8 @@ import { useAuthStore } from '~/store/auth'
|
||||
import { useUiStore } from '~/store/ui'
|
||||
import { useWebSocket } from '~/composables/useWebSocket'
|
||||
import { useGameActions } from '~/composables/useGameActions'
|
||||
import GameBoard from '~/components/Game/GameBoard.vue'
|
||||
import CurrentSituation from '~/components/Game/CurrentSituation.vue'
|
||||
import RunnersOnBase from '~/components/Game/RunnersOnBase.vue'
|
||||
import PlayByPlay from '~/components/Game/PlayByPlay.vue'
|
||||
import DecisionPanel from '~/components/Decisions/DecisionPanel.vue'
|
||||
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
|
||||
@ -412,6 +414,23 @@ const pitcherTeamAbbrev = computed(() => {
|
||||
: gameState.value.away_team_abbrev ?? ''
|
||||
})
|
||||
|
||||
// Team colors for runner/catcher matchup display
|
||||
const battingTeamColor = computed(() => {
|
||||
if (!gameState.value) return '#3b82f6'
|
||||
// Use home team dice color when home team is batting, otherwise use a default blue
|
||||
return gameState.value.half === 'bottom'
|
||||
? `#${gameState.value.home_team_dice_color}`
|
||||
: '#3b82f6'
|
||||
})
|
||||
|
||||
const fieldingTeamColor = computed(() => {
|
||||
if (!gameState.value) return '#10b981'
|
||||
// Use home team dice color when home team is fielding, otherwise use a default green
|
||||
return gameState.value.half === 'top'
|
||||
? `#${gameState.value.home_team_dice_color}`
|
||||
: '#10b981'
|
||||
})
|
||||
|
||||
// Local UI state
|
||||
const isLoading = ref(true)
|
||||
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting')
|
||||
|
||||
187
frontend-sba/components/Game/RunnerCard.vue
Normal file
187
frontend-sba/components/Game/RunnerCard.vue
Normal file
@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'runner-card',
|
||||
runner ? 'occupied' : 'empty',
|
||||
isExpanded ? 'expanded' : ''
|
||||
]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- Summary (always visible) -->
|
||||
<div class="runner-summary">
|
||||
<template v-if="runner">
|
||||
<!-- Occupied base -->
|
||||
<div class="w-10 h-10 rounded-full flex-shrink-0 overflow-hidden border-2" :style="{ borderColor: teamColor }">
|
||||
<img
|
||||
v-if="runnerPlayer?.headshot || runnerPlayer?.image"
|
||||
:src="runnerPlayer.headshot || runnerPlayer.image"
|
||||
:alt="runnerName"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<div v-else class="w-full h-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold text-sm">
|
||||
{{ base }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<div class="text-sm font-bold text-gray-900">{{ runnerName }}</div>
|
||||
<div class="text-xs text-gray-600">#{{ runnerNumber }} • {{ base }}</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:class="['h-4 w-4 transition-transform', isExpanded ? 'rotate-90' : '']"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Empty base -->
|
||||
<div class="w-10 h-10 rounded-full bg-gray-200 border-2 border-dashed border-gray-400 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-gray-400 text-sm font-bold">{{ base }}</span>
|
||||
</div>
|
||||
<div class="ml-3 text-sm text-gray-400 font-medium">Empty</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Expanded view (full card) -->
|
||||
<div v-if="runner && isExpanded" class="runner-expanded">
|
||||
<div class="bg-gradient-to-b from-blue-900 to-blue-950 rounded-b-lg overflow-hidden matchup-card-blue">
|
||||
<div class="bg-blue-800/80 px-3 py-2 flex items-center gap-2 text-white text-xs font-semibold">
|
||||
<span class="font-bold text-white/90">RUNNER</span>
|
||||
<span class="text-white/70">{{ base }}</span>
|
||||
<span class="truncate flex-1 text-right font-bold">{{ runnerName }}</span>
|
||||
</div>
|
||||
<div class="p-0">
|
||||
<img
|
||||
v-if="runnerPlayer?.image"
|
||||
:src="runnerPlayer.image"
|
||||
:alt="`${runnerName} card`"
|
||||
class="w-full h-auto"
|
||||
>
|
||||
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-blue-700 to-blue-900 flex items-center justify-center">
|
||||
<span class="text-5xl font-bold text-white/60">{{ getRunnerInitials }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { LineupPlayerState } from '~/types/game'
|
||||
import { useGameStore } from '~/store/game'
|
||||
|
||||
interface Props {
|
||||
base: '1B' | '2B' | '3B'
|
||||
runner: LineupPlayerState | null
|
||||
isExpanded: boolean
|
||||
teamColor: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const gameStore = useGameStore()
|
||||
|
||||
// Resolve player data from lineup
|
||||
const runnerPlayer = computed(() => {
|
||||
if (!props.runner) return null
|
||||
const lineupEntry = gameStore.findPlayerInLineup(props.runner.lineup_id)
|
||||
return lineupEntry?.player ?? null
|
||||
})
|
||||
|
||||
const runnerName = computed(() => {
|
||||
if (!runnerPlayer.value) return 'Unknown Runner'
|
||||
return runnerPlayer.value.name
|
||||
})
|
||||
|
||||
const runnerNumber = computed(() => {
|
||||
// Try to extract jersey number from player data if available
|
||||
// For now, default to a placeholder based on lineup_id
|
||||
return props.runner?.lineup_id?.toString().padStart(2, '0') ?? '00'
|
||||
})
|
||||
|
||||
const getRunnerInitials = computed(() => {
|
||||
if (!runnerPlayer.value) return '?'
|
||||
const parts = runnerPlayer.value.name.split(' ')
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
return runnerPlayer.value.name.substring(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (props.runner) {
|
||||
emit('click')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.runner-card {
|
||||
@apply bg-white border-l-4 rounded-lg shadow-sm transition-all duration-300;
|
||||
}
|
||||
|
||||
.runner-card.empty {
|
||||
@apply bg-gray-50 border-gray-300;
|
||||
}
|
||||
|
||||
.runner-card.occupied {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.runner-card.occupied:hover:not(.expanded) {
|
||||
@apply transform translate-x-1;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.runner-card.expanded {
|
||||
@apply transform scale-105 z-10;
|
||||
}
|
||||
|
||||
.runner-summary {
|
||||
@apply p-2 flex items-center;
|
||||
}
|
||||
|
||||
.runner-expanded {
|
||||
@apply overflow-hidden;
|
||||
animation: expandHeight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.matchup-card-blue {
|
||||
animation: pulseGlowBlue 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseGlowBlue {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 15px 2px rgba(59, 130, 246, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 30px 8px rgba(59, 130, 246, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expandHeight {
|
||||
from {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
max-height: 800px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
207
frontend-sba/components/Game/RunnersOnBase.vue
Normal file
207
frontend-sba/components/Game/RunnersOnBase.vue
Normal file
@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<!-- TODO: Spruce up the appearance of this component - improve styling, colors, animations, and visual polish -->
|
||||
<div v-if="hasRunners" class="runners-on-base-container">
|
||||
<!-- Split Layout: Runners List | Catcher Card -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 bg-white/20 rounded-lg shadow-md overflow-hidden">
|
||||
<!-- LEFT: Runners List (with expandable cards) -->
|
||||
<div class="border-r border-gray-200/50 p-4">
|
||||
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">
|
||||
Runners on Base
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- 1st Base -->
|
||||
<RunnerCard
|
||||
base="1B"
|
||||
:runner="runners.first"
|
||||
:is-expanded="selectedRunner === 'first'"
|
||||
:team-color="battingTeamColor"
|
||||
@click="toggleRunner('first')"
|
||||
/>
|
||||
|
||||
<!-- 2nd Base -->
|
||||
<RunnerCard
|
||||
base="2B"
|
||||
:runner="runners.second"
|
||||
:is-expanded="selectedRunner === 'second'"
|
||||
:team-color="battingTeamColor"
|
||||
@click="toggleRunner('second')"
|
||||
/>
|
||||
|
||||
<!-- 3rd Base -->
|
||||
<RunnerCard
|
||||
base="3B"
|
||||
:runner="runners.third"
|
||||
:is-expanded="selectedRunner === 'third'"
|
||||
:team-color="battingTeamColor"
|
||||
@click="toggleRunner('third')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Catcher Card -->
|
||||
<div class="p-4 bg-gray-50/10">
|
||||
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">
|
||||
Catcher
|
||||
</h3>
|
||||
|
||||
<!-- Collapsed state - minimal card -->
|
||||
<div
|
||||
v-if="!hasSelection"
|
||||
class="bg-white border-l-4 border-gray-600 rounded-lg p-3 shadow-sm transition-all duration-300"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-gray-600 flex-shrink-0">
|
||||
<img
|
||||
v-if="catcherPlayer?.headshot || catcherPlayer?.image"
|
||||
:src="catcherPlayer.headshot || catcherPlayer.image"
|
||||
:alt="catcherName"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<div v-else class="w-full h-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold">
|
||||
C
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-bold text-gray-900">{{ catcherName }}</div>
|
||||
<div class="text-xs text-gray-600">#{{ catcherNumber }} • C</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-xs text-gray-500 text-center">
|
||||
Click a runner to see matchup →
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded state - full card (when runner selected) -->
|
||||
<div
|
||||
v-else
|
||||
class="matchup-card bg-gradient-to-b from-green-900 to-green-950 border-2 border-green-600 rounded-xl overflow-hidden shadow-lg fade-in"
|
||||
>
|
||||
<!-- Card Header -->
|
||||
<div class="bg-green-800/80 px-3 py-2 flex items-center gap-2 text-white text-sm font-semibold">
|
||||
<span class="font-bold text-white/90" :style="{ color: fieldingTeamColor }">
|
||||
{{ fieldingTeamAbbrev }}
|
||||
</span>
|
||||
<span class="text-white/70">C</span>
|
||||
<span class="truncate flex-1 text-right font-bold">{{ catcherName }}</span>
|
||||
</div>
|
||||
<!-- Card Image -->
|
||||
<div class="p-0">
|
||||
<img
|
||||
v-if="catcherPlayer?.image"
|
||||
:src="catcherPlayer.image"
|
||||
:alt="`${catcherName} card`"
|
||||
class="w-full h-auto"
|
||||
>
|
||||
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-green-700 to-green-900 flex items-center justify-center">
|
||||
<span class="text-5xl font-bold text-white/60">{{ getCatcherInitials }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { LineupPlayerState } from '~/types/game'
|
||||
import type { Lineup } from '~/types/player'
|
||||
import { useGameStore } from '~/store/game'
|
||||
import RunnerCard from './RunnerCard.vue'
|
||||
|
||||
interface Props {
|
||||
runners: {
|
||||
first: LineupPlayerState | null
|
||||
second: LineupPlayerState | null
|
||||
third: LineupPlayerState | null
|
||||
}
|
||||
fieldingLineup?: Lineup[]
|
||||
battingTeamColor?: string
|
||||
fieldingTeamColor?: string
|
||||
battingTeamAbbrev?: string
|
||||
fieldingTeamAbbrev?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
fieldingLineup: () => [],
|
||||
battingTeamColor: '#3b82f6',
|
||||
fieldingTeamColor: '#10b981',
|
||||
battingTeamAbbrev: '',
|
||||
fieldingTeamAbbrev: '',
|
||||
})
|
||||
|
||||
const gameStore = useGameStore()
|
||||
const selectedRunner = ref<'first' | 'second' | 'third' | null>(null)
|
||||
|
||||
// Check if any runners on base
|
||||
const hasRunners = computed(() => {
|
||||
return !!(props.runners.first || props.runners.second || props.runners.third)
|
||||
})
|
||||
|
||||
const hasSelection = computed(() => selectedRunner.value !== null)
|
||||
|
||||
// Get catcher from fielding lineup
|
||||
const catcherLineup = computed(() => {
|
||||
return props.fieldingLineup.find(p => p.position === 'C')
|
||||
})
|
||||
|
||||
const catcherPlayer = computed(() => catcherLineup.value?.player ?? null)
|
||||
|
||||
const catcherName = computed(() => catcherPlayer.value?.name ?? 'Unknown Catcher')
|
||||
|
||||
const catcherNumber = computed(() => {
|
||||
// Try to extract jersey number from player data if available
|
||||
// For now, default to a placeholder
|
||||
return '00'
|
||||
})
|
||||
|
||||
const getCatcherInitials = computed(() => {
|
||||
if (!catcherPlayer.value) return 'C'
|
||||
const parts = catcherPlayer.value.name.split(' ')
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
return catcherPlayer.value.name.substring(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
function toggleRunner(base: 'first' | 'second' | 'third') {
|
||||
// Can't select empty base
|
||||
if (!props.runners[base]) return
|
||||
|
||||
// Toggle selection
|
||||
if (selectedRunner.value === base) {
|
||||
selectedRunner.value = null
|
||||
} else {
|
||||
selectedRunner.value = base
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.runners-on-base-container {
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
.matchup-card {
|
||||
animation: pulseGlowGreen 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseGlowGreen {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 15px 2px rgba(16, 185, 129, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 30px 8px rgba(16, 185, 129, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
606
frontend-sba/tests/unit/components/Game/RunnerCard.spec.ts
Normal file
606
frontend-sba/tests/unit/components/Game/RunnerCard.spec.ts
Normal file
@ -0,0 +1,606 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import RunnerCard from "~/components/Game/RunnerCard.vue";
|
||||
import { useGameStore } from "~/store/game";
|
||||
import type { LineupPlayerState } from "~/types/game";
|
||||
|
||||
describe("RunnerCard", () => {
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
const mockRunner: LineupPlayerState = {
|
||||
lineup_id: 1,
|
||||
batting_order: 1,
|
||||
position: "LF",
|
||||
card_id: 101,
|
||||
};
|
||||
|
||||
describe("empty base state", () => {
|
||||
it("renders empty state when no runner provided", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: null,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find(".runner-card.empty").exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("Empty");
|
||||
});
|
||||
|
||||
it("displays base label for empty base", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "2B",
|
||||
runner: null,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("2B");
|
||||
});
|
||||
|
||||
it("shows hollow circle for empty base", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "3B",
|
||||
runner: null,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
const circle = wrapper.find(".rounded-full.border-dashed");
|
||||
expect(circle.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not emit click event for empty base", async () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: null,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.trigger("click");
|
||||
expect(wrapper.emitted("click")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("occupied base state", () => {
|
||||
beforeEach(() => {
|
||||
const gameStore = useGameStore();
|
||||
// Set game state first so updateLineup knows the team ID
|
||||
gameStore.setGameState({
|
||||
id: 1,
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
status: "active",
|
||||
inning: 1,
|
||||
half: "top",
|
||||
outs: 0,
|
||||
home_score: 0,
|
||||
away_score: 0,
|
||||
home_team_abbrev: "NYY",
|
||||
away_team_abbrev: "BOS",
|
||||
home_team_dice_color: "3b82f6",
|
||||
current_batter: null,
|
||||
current_pitcher: null,
|
||||
on_first: null,
|
||||
on_second: null,
|
||||
on_third: null,
|
||||
decision_phase: "idle",
|
||||
play_count: 0,
|
||||
});
|
||||
|
||||
gameStore.updateLineup(1, [
|
||||
{
|
||||
id: 1,
|
||||
lineup_id: 1,
|
||||
team_id: 1,
|
||||
batting_order: 1,
|
||||
position: "LF",
|
||||
is_active: true,
|
||||
player: {
|
||||
id: 101,
|
||||
name: "Mike Trout",
|
||||
image: "https://example.com/trout.jpg",
|
||||
headshot: "https://example.com/trout-headshot.jpg",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders occupied state when runner provided", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find(".runner-card.occupied").exists()).toBe(true);
|
||||
expect(wrapper.find(".runner-card.empty").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("displays runner name", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("Mike Trout");
|
||||
});
|
||||
|
||||
it("displays base label", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "2B",
|
||||
runner: mockRunner,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("2B");
|
||||
});
|
||||
|
||||
it("displays runner number based on lineup_id", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("#01");
|
||||
});
|
||||
|
||||
it("displays player headshot when available", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
const img = wrapper.find('img[alt="Mike Trout"]');
|
||||
expect(img.exists()).toBe(true);
|
||||
expect(img.attributes("src")).toBe(
|
||||
"https://example.com/trout-headshot.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies team color to border", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: false,
|
||||
teamColor: "#ff0000",
|
||||
},
|
||||
});
|
||||
|
||||
const avatar = wrapper.find(".rounded-full.border-2");
|
||||
expect(avatar.attributes("style")).toContain(
|
||||
"border-color: #ff0000",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows chevron icon when occupied", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
const chevron = wrapper.find("svg");
|
||||
expect(chevron.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("emits click event when clicked", async () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.trigger("click");
|
||||
expect(wrapper.emitted("click")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expanded state", () => {
|
||||
beforeEach(() => {
|
||||
const gameStore = useGameStore();
|
||||
gameStore.setGameState({
|
||||
id: 1,
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
status: "active",
|
||||
inning: 1,
|
||||
half: "top",
|
||||
outs: 0,
|
||||
on_base_code: 0,
|
||||
home_team: {
|
||||
id: 1,
|
||||
name: "Home Team",
|
||||
abbreviation: "HOME",
|
||||
dice_color: "3b82f6",
|
||||
},
|
||||
away_team: {
|
||||
id: 2,
|
||||
name: "Away Team",
|
||||
abbreviation: "AWAY",
|
||||
dice_color: "10b981",
|
||||
},
|
||||
});
|
||||
gameStore.updateLineup(1, [
|
||||
{
|
||||
id: 1,
|
||||
lineup_id: 1,
|
||||
team_id: 1,
|
||||
batting_order: 1,
|
||||
position: "LF",
|
||||
is_active: true,
|
||||
player: {
|
||||
id: 101,
|
||||
name: "Mike Trout",
|
||||
image: "https://example.com/trout-card.jpg",
|
||||
headshot: "https://example.com/trout-headshot.jpg",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not show expanded view when collapsed", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find(".runner-expanded").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows expanded view when isExpanded is true", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: true,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find(".runner-expanded").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("displays full player card image when expanded", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: true,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
const cardImg = wrapper.find(
|
||||
'.runner-expanded img[alt="Mike Trout card"]',
|
||||
);
|
||||
expect(cardImg.exists()).toBe(true);
|
||||
expect(cardImg.attributes("src")).toBe(
|
||||
"https://example.com/trout-card.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows player initials when no card image available", () => {
|
||||
const gameStore = useGameStore();
|
||||
gameStore.setGameState({
|
||||
id: 1,
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
status: "active",
|
||||
inning: 1,
|
||||
half: "top",
|
||||
outs: 0,
|
||||
on_base_code: 0,
|
||||
home_team: {
|
||||
id: 1,
|
||||
name: "Home Team",
|
||||
abbreviation: "HOME",
|
||||
dice_color: "3b82f6",
|
||||
},
|
||||
away_team: {
|
||||
id: 2,
|
||||
name: "Away Team",
|
||||
abbreviation: "AWAY",
|
||||
dice_color: "10b981",
|
||||
},
|
||||
});
|
||||
gameStore.updateLineup(1, [
|
||||
{
|
||||
id: 1,
|
||||
lineup_id: 1,
|
||||
team_id: 1,
|
||||
batting_order: 1,
|
||||
position: "LF",
|
||||
is_active: true,
|
||||
player: {
|
||||
id: 101,
|
||||
name: "Mike Trout",
|
||||
image: "",
|
||||
headshot: "",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: true,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("MT");
|
||||
});
|
||||
|
||||
it('displays "RUNNER" label in expanded header', () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "2B",
|
||||
runner: mockRunner,
|
||||
isExpanded: true,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find(".runner-expanded").text()).toContain("RUNNER");
|
||||
});
|
||||
|
||||
it("applies expanded class when isExpanded is true", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: true,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find(".runner-card.expanded").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("rotates chevron when expanded", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: true,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
const chevron = wrapper.find("svg");
|
||||
expect(chevron.classes()).toContain("rotate-90");
|
||||
});
|
||||
});
|
||||
|
||||
describe("player name handling", () => {
|
||||
it('shows "Unknown Runner" when player not found in store', () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("Unknown Runner");
|
||||
});
|
||||
|
||||
it("extracts initials from first and last name", () => {
|
||||
const gameStore = useGameStore();
|
||||
gameStore.setGameState({
|
||||
id: 1,
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
status: "active",
|
||||
inning: 1,
|
||||
half: "top",
|
||||
outs: 0,
|
||||
on_base_code: 0,
|
||||
home_team: {
|
||||
id: 1,
|
||||
name: "Home Team",
|
||||
abbreviation: "HOME",
|
||||
dice_color: "3b82f6",
|
||||
},
|
||||
away_team: {
|
||||
id: 2,
|
||||
name: "Away Team",
|
||||
abbreviation: "AWAY",
|
||||
dice_color: "10b981",
|
||||
},
|
||||
});
|
||||
gameStore.updateLineup(1, [
|
||||
{
|
||||
id: 1,
|
||||
lineup_id: 1,
|
||||
team_id: 1,
|
||||
batting_order: 1,
|
||||
position: "LF",
|
||||
is_active: true,
|
||||
player: {
|
||||
id: 101,
|
||||
name: "Aaron Donald Judge",
|
||||
image: "",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: true,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
// Should use first and last name (A + J)
|
||||
expect(wrapper.text()).toContain("AJ");
|
||||
});
|
||||
|
||||
it("handles single-word names", () => {
|
||||
const gameStore = useGameStore();
|
||||
gameStore.setGameState({
|
||||
id: 1,
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
status: "active",
|
||||
inning: 1,
|
||||
half: "top",
|
||||
outs: 0,
|
||||
on_base_code: 0,
|
||||
home_team: {
|
||||
id: 1,
|
||||
name: "Home Team",
|
||||
abbreviation: "HOME",
|
||||
dice_color: "3b82f6",
|
||||
},
|
||||
away_team: {
|
||||
id: 2,
|
||||
name: "Away Team",
|
||||
abbreviation: "AWAY",
|
||||
dice_color: "10b981",
|
||||
},
|
||||
});
|
||||
gameStore.updateLineup(1, [
|
||||
{
|
||||
id: 1,
|
||||
lineup_id: 1,
|
||||
team_id: 1,
|
||||
batting_order: 1,
|
||||
position: "LF",
|
||||
is_active: true,
|
||||
player: {
|
||||
id: 101,
|
||||
name: "Pele",
|
||||
image: "",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: mockRunner,
|
||||
isExpanded: true,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("PE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("base label variations", () => {
|
||||
it("displays 1B correctly", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "1B",
|
||||
runner: null,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("1B");
|
||||
});
|
||||
|
||||
it("displays 2B correctly", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "2B",
|
||||
runner: null,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("2B");
|
||||
});
|
||||
|
||||
it("displays 3B correctly", () => {
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
base: "3B",
|
||||
runner: null,
|
||||
isExpanded: false,
|
||||
teamColor: "#3b82f6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("3B");
|
||||
});
|
||||
});
|
||||
});
|
||||
577
frontend-sba/tests/unit/components/Game/RunnersOnBase.spec.ts
Normal file
577
frontend-sba/tests/unit/components/Game/RunnersOnBase.spec.ts
Normal file
@ -0,0 +1,577 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import RunnersOnBase from "~/components/Game/RunnersOnBase.vue";
|
||||
import RunnerCard from "~/components/Game/RunnerCard.vue";
|
||||
import { useGameStore } from "~/store/game";
|
||||
import type { LineupPlayerState, Lineup } from "~/types/game";
|
||||
|
||||
describe("RunnersOnBase", () => {
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
const mockRunnerFirst: LineupPlayerState = {
|
||||
lineup_id: 1,
|
||||
batting_order: 1,
|
||||
position: "LF",
|
||||
card_id: 101,
|
||||
};
|
||||
|
||||
const mockRunnerSecond: LineupPlayerState = {
|
||||
lineup_id: 2,
|
||||
batting_order: 2,
|
||||
position: "CF",
|
||||
card_id: 102,
|
||||
};
|
||||
|
||||
const mockRunnerThird: LineupPlayerState = {
|
||||
lineup_id: 3,
|
||||
batting_order: 3,
|
||||
position: "RF",
|
||||
card_id: 103,
|
||||
};
|
||||
|
||||
const mockCatcher: Lineup = {
|
||||
id: 1,
|
||||
lineup_id: 4,
|
||||
team_id: 1,
|
||||
batting_order: 4,
|
||||
position: "C",
|
||||
is_active: true,
|
||||
player: {
|
||||
id: 104,
|
||||
name: "Buster Posey",
|
||||
image: "https://example.com/posey.jpg",
|
||||
headshot: "https://example.com/posey-headshot.jpg",
|
||||
},
|
||||
};
|
||||
|
||||
const mockFieldingLineup: Lineup[] = [mockCatcher];
|
||||
|
||||
describe("component visibility", () => {
|
||||
it("does not render when no runners on base", () => {
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: { first: null, second: null, third: null },
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
battingTeamColor: "#3b82f6",
|
||||
fieldingTeamColor: "#10b981",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find(".runners-on-base-container").exists()).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders when at least one runner on base", () => {
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: null,
|
||||
second: mockRunnerSecond,
|
||||
third: null,
|
||||
},
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
battingTeamColor: "#3b82f6",
|
||||
fieldingTeamColor: "#10b981",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find(".runners-on-base-container").exists()).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders when bases loaded", () => {
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: mockRunnerFirst,
|
||||
second: mockRunnerSecond,
|
||||
third: mockRunnerThird,
|
||||
},
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
battingTeamColor: "#3b82f6",
|
||||
fieldingTeamColor: "#10b981",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find(".runners-on-base-container").exists()).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runner cards", () => {
|
||||
it("renders three RunnerCard components", () => {
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: mockRunnerFirst,
|
||||
second: null,
|
||||
third: null,
|
||||
},
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
battingTeamColor: "#3b82f6",
|
||||
fieldingTeamColor: "#10b981",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
const runnerCards = wrapper.findAllComponents(RunnerCard);
|
||||
expect(runnerCards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("passes correct base labels to RunnerCard components", async () => {
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: mockRunnerFirst,
|
||||
second: null,
|
||||
third: null,
|
||||
},
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
battingTeamColor: "#3b82f6",
|
||||
fieldingTeamColor: "#10b981",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const runnerCards = wrapper.findAllComponents(RunnerCard);
|
||||
expect(runnerCards.length).toBeGreaterThanOrEqual(3);
|
||||
expect(runnerCards[0].props("base")).toBe("1B");
|
||||
expect(runnerCards[1].props("base")).toBe("2B");
|
||||
expect(runnerCards[2].props("base")).toBe("3B");
|
||||
});
|
||||
|
||||
it("passes runner data to RunnerCard components", () => {
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: mockRunnerFirst,
|
||||
second: mockRunnerSecond,
|
||||
third: null,
|
||||
},
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
battingTeamColor: "#3b82f6",
|
||||
fieldingTeamColor: "#10b981",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
const runnerCards = wrapper.findAllComponents(RunnerCard);
|
||||
expect(runnerCards[0].props("runner")).toEqual(mockRunnerFirst);
|
||||
expect(runnerCards[1].props("runner")).toEqual(mockRunnerSecond);
|
||||
expect(runnerCards[2].props("runner")).toBeNull();
|
||||
});
|
||||
|
||||
it("passes team color to RunnerCard components", () => {
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: mockRunnerFirst,
|
||||
second: null,
|
||||
third: null,
|
||||
},
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
battingTeamColor: "#ff0000",
|
||||
fieldingTeamColor: "#00ff00",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
const runnerCards = wrapper.findAllComponents(RunnerCard);
|
||||
runnerCards.forEach((card) => {
|
||||
expect(card.props("teamColor")).toBe("#ff0000");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("catcher display", () => {
|
||||
it("shows collapsed catcher card by default", () => {
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: mockRunnerFirst,
|
||||
second: null,
|
||||
third: null,
|
||||
},
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
battingTeamColor: "#3b82f6",
|
||||
fieldingTeamColor: "#10b981",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
// Collapsed state shows border-l-4, expanded state shows .matchup-card
|
||||
expect(wrapper.find(".border-l-4.border-gray-600").exists()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(wrapper.find(".matchup-card").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("displays catcher name", () => {
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: mockRunnerFirst,
|
||||
second: null,
|
||||
third: null,
|
||||
},
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
battingTeamColor: "#3b82f6",
|
||||
fieldingTeamColor: "#10b981",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("Buster Posey");
|
||||
});
|
||||
|
||||
it('shows "Unknown Catcher" when no catcher in lineup', () => {
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: mockRunnerFirst,
|
||||
second: null,
|
||||
third: null,
|
||||
},
|
||||
fieldingLineup: [],
|
||||
battingTeamColor: "#3b82f6",
|
||||
fieldingTeamColor: "#10b981",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("Unknown Catcher");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runner selection", () => {
|
||||
it("expands catcher card when runner is selected", async () => {
|
||||
const gameStore = useGameStore();
|
||||
gameStore.setGameState({
|
||||
id: 1,
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
status: "active",
|
||||
inning: 1,
|
||||
half: "top",
|
||||
outs: 0,
|
||||
home_score: 0,
|
||||
away_score: 0,
|
||||
home_team_abbrev: "NYY",
|
||||
away_team_abbrev: "BOS",
|
||||
home_team_dice_color: "3b82f6",
|
||||
current_batter: null,
|
||||
current_pitcher: null,
|
||||
on_first: null,
|
||||
on_second: null,
|
||||
on_third: null,
|
||||
decision_phase: "idle",
|
||||
play_count: 0,
|
||||
});
|
||||
gameStore.updateLineup(1, [
|
||||
{
|
||||
id: 1,
|
||||
lineup_id: 1,
|
||||
team_id: 1,
|
||||
batting_order: 1,
|
||||
position: "LF",
|
||||
is_active: true,
|
||||
player: {
|
||||
id: 101,
|
||||
name: "Mike Trout",
|
||||
image: "https://example.com/trout.jpg",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: mockRunnerFirst,
|
||||
second: null,
|
||||
third: null,
|
||||
},
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
battingTeamColor: "#3b82f6",
|
||||
fieldingTeamColor: "#10b981",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
const runnerCards = wrapper.findAllComponents(RunnerCard);
|
||||
await runnerCards[0].trigger("click");
|
||||
|
||||
// When runner selected, collapsed state hidden and expanded state shown
|
||||
expect(wrapper.find(".border-l-4.border-gray-600").exists()).toBe(
|
||||
false,
|
||||
);
|
||||
expect(wrapper.find(".matchup-card").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("collapses catcher card when runner is deselected", async () => {
|
||||
const gameStore = useGameStore();
|
||||
gameStore.setGameState({
|
||||
id: 1,
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
status: "active",
|
||||
inning: 1,
|
||||
half: "top",
|
||||
outs: 0,
|
||||
home_score: 0,
|
||||
away_score: 0,
|
||||
home_team_abbrev: "NYY",
|
||||
away_team_abbrev: "BOS",
|
||||
home_team_dice_color: "3b82f6",
|
||||
current_batter: null,
|
||||
current_pitcher: null,
|
||||
on_first: null,
|
||||
on_second: null,
|
||||
on_third: null,
|
||||
decision_phase: "idle",
|
||||
play_count: 0,
|
||||
});
|
||||
gameStore.updateLineup(1, [
|
||||
{
|
||||
id: 1,
|
||||
lineup_id: 1,
|
||||
team_id: 1,
|
||||
batting_order: 1,
|
||||
position: "LF",
|
||||
is_active: true,
|
||||
player: {
|
||||
id: 101,
|
||||
name: "Mike Trout",
|
||||
image: "https://example.com/trout.jpg",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: mockRunnerFirst,
|
||||
second: null,
|
||||
third: null,
|
||||
},
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
battingTeamColor: "#3b82f6",
|
||||
fieldingTeamColor: "#10b981",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
const runnerCards = wrapper.findAllComponents(RunnerCard);
|
||||
|
||||
// Click to expand
|
||||
await runnerCards[0].trigger("click");
|
||||
expect(wrapper.find(".matchup-card").exists()).toBe(true);
|
||||
|
||||
// Click again to collapse
|
||||
await runnerCards[0].trigger("click");
|
||||
expect(wrapper.find(".border-l-4.border-gray-600").exists()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(wrapper.find(".matchup-card").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("switches selection when clicking different runner", async () => {
|
||||
const gameStore = useGameStore();
|
||||
gameStore.setGameState({
|
||||
id: 1,
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
status: "active",
|
||||
inning: 1,
|
||||
half: "top",
|
||||
outs: 0,
|
||||
home_score: 0,
|
||||
away_score: 0,
|
||||
home_team_abbrev: "NYY",
|
||||
away_team_abbrev: "BOS",
|
||||
home_team_dice_color: "3b82f6",
|
||||
current_batter: null,
|
||||
current_pitcher: null,
|
||||
on_first: null,
|
||||
on_second: null,
|
||||
on_third: null,
|
||||
decision_phase: "idle",
|
||||
play_count: 0,
|
||||
});
|
||||
gameStore.updateLineup(1, [
|
||||
{
|
||||
id: 1,
|
||||
lineup_id: 1,
|
||||
team_id: 1,
|
||||
batting_order: 1,
|
||||
position: "LF",
|
||||
is_active: true,
|
||||
player: {
|
||||
id: 101,
|
||||
name: "Mike Trout",
|
||||
image: "https://example.com/trout.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
lineup_id: 2,
|
||||
team_id: 1,
|
||||
batting_order: 2,
|
||||
position: "CF",
|
||||
is_active: true,
|
||||
player: {
|
||||
id: 102,
|
||||
name: "Aaron Judge",
|
||||
image: "https://example.com/judge.jpg",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: mockRunnerFirst,
|
||||
second: mockRunnerSecond,
|
||||
third: null,
|
||||
},
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
battingTeamColor: "#3b82f6",
|
||||
fieldingTeamColor: "#10b981",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
const runnerCards = wrapper.findAllComponents(RunnerCard);
|
||||
|
||||
// Select first runner
|
||||
await runnerCards[0].trigger("click");
|
||||
expect(runnerCards[0].props("isExpanded")).toBe(true);
|
||||
expect(runnerCards[1].props("isExpanded")).toBe(false);
|
||||
|
||||
// Select second runner
|
||||
await runnerCards[1].trigger("click");
|
||||
expect(runnerCards[0].props("isExpanded")).toBe(false);
|
||||
expect(runnerCards[1].props("isExpanded")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("team information", () => {
|
||||
it("displays team abbreviations when provided", async () => {
|
||||
const gameStore = useGameStore();
|
||||
gameStore.setGameState({
|
||||
id: 1,
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
status: "active",
|
||||
inning: 1,
|
||||
half: "top",
|
||||
outs: 0,
|
||||
home_score: 0,
|
||||
away_score: 0,
|
||||
home_team_abbrev: "NYY",
|
||||
away_team_abbrev: "BOS",
|
||||
home_team_dice_color: "3b82f6",
|
||||
current_batter: null,
|
||||
current_pitcher: null,
|
||||
on_first: null,
|
||||
on_second: null,
|
||||
on_third: null,
|
||||
decision_phase: "idle",
|
||||
play_count: 0,
|
||||
});
|
||||
gameStore.updateLineup(1, [
|
||||
{
|
||||
id: 1,
|
||||
lineup_id: 1,
|
||||
team_id: 1,
|
||||
batting_order: 1,
|
||||
position: "LF",
|
||||
is_active: true,
|
||||
player: {
|
||||
id: 101,
|
||||
name: "Mike Trout",
|
||||
image: "https://example.com/trout.jpg",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: mockRunnerFirst,
|
||||
second: null,
|
||||
third: null,
|
||||
},
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
battingTeamColor: "#3b82f6",
|
||||
fieldingTeamColor: "#10b981",
|
||||
battingTeamAbbrev: "BOS",
|
||||
fieldingTeamAbbrev: "NYY",
|
||||
},
|
||||
});
|
||||
|
||||
// Click runner to expand and show team abbreviation
|
||||
const runnerCards = wrapper.findAllComponents(RunnerCard);
|
||||
await runnerCards[0].trigger("click");
|
||||
|
||||
expect(wrapper.text()).toContain("NYY");
|
||||
});
|
||||
|
||||
it("uses default colors when not provided", () => {
|
||||
const wrapper = mount(RunnersOnBase, {
|
||||
global: { plugins: [pinia] },
|
||||
props: {
|
||||
runners: {
|
||||
first: mockRunnerFirst,
|
||||
second: null,
|
||||
third: null,
|
||||
},
|
||||
fieldingLineup: mockFieldingLineup,
|
||||
},
|
||||
});
|
||||
|
||||
const runnerCards = wrapper.findAllComponents(RunnerCard);
|
||||
expect(runnerCards[0].props("teamColor")).toBe("#3b82f6");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user