Enhanced PlayByPlay with a tabbed interface: - "Recent" tab shows plays from current half inning only - "Scoring" tab shows all plays where runs were scored - Badge counts on each tab show number of matching plays - Tab-aware empty states with contextual messaging - Footer shows total game plays count Removed unused showFilters prop and showAllPlays toggle. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
401 lines
15 KiB
Vue
401 lines
15 KiB
Vue
<template>
|
|
<div class="play-by-play-container">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
Play-by-Play
|
|
</h2>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
|
<button
|
|
class="flex-1 py-2 px-4 text-sm font-medium border-b-2 transition-colors"
|
|
:class="activeTab === 'recent'
|
|
? 'text-primary border-primary'
|
|
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'"
|
|
@click="activeTab = 'recent'"
|
|
>
|
|
Recent
|
|
<span
|
|
v-if="recentPlays.length > 0"
|
|
class="ml-1.5 px-1.5 py-0.5 text-xs rounded-full"
|
|
:class="activeTab === 'recent'
|
|
? 'bg-primary/20 text-primary'
|
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'"
|
|
>
|
|
{{ recentPlays.length }}
|
|
</span>
|
|
</button>
|
|
<button
|
|
class="flex-1 py-2 px-4 text-sm font-medium border-b-2 transition-colors"
|
|
:class="activeTab === 'scoring'
|
|
? 'text-green-600 border-green-600'
|
|
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'"
|
|
@click="activeTab = 'scoring'"
|
|
>
|
|
Scoring
|
|
<span
|
|
v-if="scoringPlays.length > 0"
|
|
class="ml-1.5 px-1.5 py-0.5 text-xs rounded-full"
|
|
:class="activeTab === 'scoring'
|
|
? 'bg-green-100 dark:bg-green-900/30 text-green-600'
|
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'"
|
|
>
|
|
{{ scoringPlays.length }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Plays Feed -->
|
|
<div
|
|
class="space-y-2 overflow-y-auto"
|
|
:class="scrollable ? 'max-h-96' : ''"
|
|
:style="maxHeight ? `max-height: ${maxHeight}px` : ''"
|
|
>
|
|
<!-- Empty State -->
|
|
<div
|
|
v-if="displayedPlays.length === 0"
|
|
class="text-center py-12 px-4"
|
|
>
|
|
<div class="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
|
<svg v-if="activeTab === 'recent'" xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
|
</svg>
|
|
</div>
|
|
<p class="text-gray-500 dark:text-gray-400 font-medium">
|
|
{{ activeTab === 'recent' ? 'No plays this half inning' : 'No runs scored yet' }}
|
|
</p>
|
|
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">
|
|
{{ activeTab === 'recent' ? 'Plays will appear here as they happen' : 'Scoring plays will appear here' }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Play Items -->
|
|
<TransitionGroup name="play-slide">
|
|
<div
|
|
v-for="play in displayedPlays"
|
|
:key="play.play_number"
|
|
class="play-item group"
|
|
>
|
|
<!-- Play Card -->
|
|
<div
|
|
class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm hover:shadow-md transition-all cursor-pointer border-l-4"
|
|
:class="getPlayBorderColor(play)"
|
|
>
|
|
<!-- Play Header -->
|
|
<div class="flex items-start justify-between mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<!-- Play Icon -->
|
|
<div
|
|
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
|
|
:class="getPlayIconClass(play)"
|
|
>
|
|
<component :is="getPlayIcon(play)" class="w-4 h-4" />
|
|
</div>
|
|
|
|
<!-- Inning Badge -->
|
|
<span class="text-xs font-medium px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-full">
|
|
{{ formatInning(play) }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Play Number -->
|
|
<span class="text-xs text-gray-400 font-mono">
|
|
#{{ play.play_number }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Batter Name (if available) -->
|
|
<p v-if="getBatterName(play)" class="text-xs text-gray-500 dark:text-gray-400 mb-1 font-medium">
|
|
{{ getBatterName(play) }} batting
|
|
</p>
|
|
|
|
<!-- Play Description -->
|
|
<p
|
|
class="text-sm text-gray-900 dark:text-gray-100 font-medium leading-relaxed"
|
|
:class="{'line-clamp-2 group-hover:line-clamp-none': compact}"
|
|
>
|
|
{{ play.description }}
|
|
</p>
|
|
|
|
<!-- Runner Movements (if any) -->
|
|
<div v-if="hasRunnerMovements(play)" class="mt-2 space-y-0.5">
|
|
<p
|
|
v-for="(movement, idx) in getRunnerAdvancements(play)"
|
|
:key="idx"
|
|
class="text-xs text-gray-600 dark:text-gray-400 flex items-center gap-1"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
|
</svg>
|
|
<span>{{ movement }}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Play Stats (Runs/Outs) -->
|
|
<div class="flex items-center gap-3 mt-3">
|
|
<!-- Runs Scored -->
|
|
<div
|
|
v-if="play.runs_scored > 0"
|
|
class="flex items-center gap-1 text-xs font-semibold text-green-600 dark:text-green-400"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
|
</svg>
|
|
<span>{{ play.runs_scored }} {{ play.runs_scored === 1 ? 'Run' : 'Runs' }}</span>
|
|
</div>
|
|
|
|
<!-- Outs Recorded -->
|
|
<div
|
|
v-if="play.outs_recorded > 0"
|
|
class="flex items-center gap-1 text-xs font-semibold text-red-600 dark:text-red-400"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-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>
|
|
<span>{{ play.outs_recorded }} {{ play.outs_recorded === 1 ? 'Out' : 'Outs' }}</span>
|
|
</div>
|
|
|
|
<!-- Outcome Badge -->
|
|
<div class="ml-auto">
|
|
<span
|
|
class="text-xs px-2 py-0.5 rounded-full font-medium"
|
|
:class="getOutcomeBadgeClass(play)"
|
|
>
|
|
{{ formatOutcome(play.outcome) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TransitionGroup>
|
|
</div>
|
|
|
|
<!-- Total play count footer -->
|
|
<div
|
|
v-if="plays && plays.length > 0"
|
|
class="text-center mt-4 pt-3 border-t border-gray-200 dark:border-gray-700"
|
|
>
|
|
<span class="text-xs text-gray-400 dark:text-gray-500">
|
|
{{ plays.length }} total {{ plays.length === 1 ? 'play' : 'plays' }} this game
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { PlayResult, RunnerAdvancement } from '~/types/game'
|
|
import { h } from 'vue'
|
|
import { useGameStore } from '~/store/game'
|
|
|
|
interface Props {
|
|
plays?: PlayResult[]
|
|
limit?: number
|
|
compact?: boolean
|
|
scrollable?: boolean
|
|
maxHeight?: number
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
plays: () => [],
|
|
limit: 10,
|
|
compact: false,
|
|
scrollable: true,
|
|
maxHeight: 0
|
|
})
|
|
|
|
// Store for player name lookup and current game state
|
|
const gameStore = useGameStore()
|
|
|
|
// State
|
|
type TabType = 'recent' | 'scoring'
|
|
const activeTab = ref<TabType>('recent')
|
|
|
|
// Current game state for filtering recent plays
|
|
const currentInning = computed(() => gameStore.currentInning)
|
|
const currentHalf = computed(() => gameStore.currentHalf)
|
|
|
|
// Helper functions for player names
|
|
const getPlayerName = (lineupId: number | undefined): string | null => {
|
|
if (!lineupId) return null
|
|
const lineup = gameStore.findPlayerInLineup(lineupId)
|
|
return lineup?.player?.name || null
|
|
}
|
|
|
|
const getBatterName = (play: PlayResult): string | null => {
|
|
return getPlayerName(play.batter_lineup_id)
|
|
}
|
|
|
|
const formatBaseName = (base: number): string => {
|
|
switch (base) {
|
|
case 0: return 'Home'
|
|
case 1: return '1st'
|
|
case 2: return '2nd'
|
|
case 3: return '3rd'
|
|
case 4: return 'Home'
|
|
default: return `${base}B`
|
|
}
|
|
}
|
|
|
|
const formatRunnerAdvancement = (adv: RunnerAdvancement): string => {
|
|
const playerName = getPlayerName(adv.lineup_id)
|
|
const name = playerName || `R${adv.from}`
|
|
const from = formatBaseName(adv.from)
|
|
const to = adv.to === 4 ? 'scores' : formatBaseName(adv.to)
|
|
|
|
if (adv.is_out) {
|
|
return `${name} out at ${to}`
|
|
} else if (adv.to === 4) {
|
|
return `${name} ${to}`
|
|
} else {
|
|
return `${name}: ${from} → ${to}`
|
|
}
|
|
}
|
|
|
|
const getRunnerAdvancements = (play: PlayResult): string[] => {
|
|
if (!play.runners_advanced || play.runners_advanced.length === 0) {
|
|
return []
|
|
}
|
|
return play.runners_advanced.map(formatRunnerAdvancement)
|
|
}
|
|
|
|
const hasRunnerMovements = (play: PlayResult): boolean => {
|
|
return play.runners_advanced && play.runners_advanced.length > 0
|
|
}
|
|
|
|
// Computed - Filtered play lists
|
|
const recentPlays = computed(() => {
|
|
if (!props.plays || props.plays.length === 0) return []
|
|
|
|
// Filter plays from current half inning
|
|
return props.plays.filter(play =>
|
|
play.inning === currentInning.value && play.half === currentHalf.value
|
|
).sort((a, b) => b.play_number - a.play_number)
|
|
})
|
|
|
|
const scoringPlays = computed(() => {
|
|
if (!props.plays || props.plays.length === 0) return []
|
|
|
|
// Filter plays where runs were scored
|
|
return props.plays.filter(play => play.runs_scored > 0)
|
|
.sort((a, b) => b.play_number - a.play_number)
|
|
})
|
|
|
|
const displayedPlays = computed(() => {
|
|
if (activeTab.value === 'recent') {
|
|
return recentPlays.value
|
|
} else {
|
|
return scoringPlays.value
|
|
}
|
|
})
|
|
|
|
// Methods
|
|
const formatInning = (play: PlayResult): string => {
|
|
if (!play.inning) return 'Inning ?'
|
|
const half = play.half === 'top' ? 'Top' : play.half === 'bottom' ? 'Bot' : ''
|
|
return half ? `${half} ${play.inning}` : `Inning ${play.inning}`
|
|
}
|
|
|
|
const formatOutcome = (outcome: string): string => {
|
|
// Convert SINGLE_1 -> Single, STRIKEOUT -> K, etc.
|
|
if (outcome.startsWith('SINGLE')) return 'Single'
|
|
if (outcome.startsWith('DOUBLE')) return 'Double'
|
|
if (outcome.startsWith('TRIPLE')) return 'Triple'
|
|
if (outcome.includes('HOMERUN')) return 'Home Run'
|
|
if (outcome.includes('STRIKEOUT')) return 'K'
|
|
if (outcome.includes('WALK')) return 'BB'
|
|
if (outcome.includes('GROUNDOUT')) return 'Groundout'
|
|
if (outcome.includes('FLYOUT')) return 'Flyout'
|
|
if (outcome.includes('LINEOUT')) return 'Lineout'
|
|
return outcome.replace(/_/g, ' ')
|
|
}
|
|
|
|
const getPlayBorderColor = (play: PlayResult): string => {
|
|
if (play.runs_scored > 0) return 'border-green-500'
|
|
if (play.outs_recorded > 0) return 'border-red-500'
|
|
if (play.outcome.includes('SINGLE') || play.outcome.includes('DOUBLE') || play.outcome.includes('TRIPLE') || play.outcome.includes('HOMERUN')) {
|
|
return 'border-blue-500'
|
|
}
|
|
return 'border-gray-300 dark:border-gray-700'
|
|
}
|
|
|
|
const getPlayIconClass = (play: PlayResult): string => {
|
|
if (play.runs_scored > 0) return 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400'
|
|
if (play.outs_recorded > 0) return 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400'
|
|
if (play.outcome.includes('SINGLE') || play.outcome.includes('DOUBLE') || play.outcome.includes('TRIPLE') || play.outcome.includes('HOMERUN')) {
|
|
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
|
}
|
|
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
|
}
|
|
|
|
const getPlayIcon = (play: PlayResult) => {
|
|
// Return SVG path as component
|
|
if (play.runs_scored > 0) {
|
|
return h('svg', { xmlns: 'http://www.w3.org/2000/svg', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
|
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M5 10l7-7m0 0l7 7m-7-7v18' })
|
|
])
|
|
}
|
|
if (play.outs_recorded > 0) {
|
|
return h('svg', { xmlns: 'http://www.w3.org/2000/svg', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
|
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M6 18L18 6M6 6l12 12' })
|
|
])
|
|
}
|
|
// Default baseball icon
|
|
return h('svg', { xmlns: 'http://www.w3.org/2000/svg', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
|
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M21 12a9 9 0 11-18 0 9 9 0 0118 0z' }),
|
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z' })
|
|
])
|
|
}
|
|
|
|
const getOutcomeBadgeClass = (play: PlayResult): string => {
|
|
if (play.outcome.includes('HOMERUN')) return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
|
|
if (play.outcome.includes('SINGLE') || play.outcome.includes('DOUBLE') || play.outcome.includes('TRIPLE')) {
|
|
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
|
|
}
|
|
if (play.outcome.includes('STRIKEOUT')) return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
|
|
if (play.outcome.includes('WALK')) return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Play slide-in animation */
|
|
.play-slide-enter-active {
|
|
transition: all 0.4s ease-out;
|
|
}
|
|
|
|
.play-slide-leave-active {
|
|
transition: all 0.3s ease-in;
|
|
}
|
|
|
|
.play-slide-enter-from {
|
|
opacity: 0;
|
|
transform: translateY(-20px);
|
|
}
|
|
|
|
.play-slide-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(20px);
|
|
}
|
|
|
|
/* Line clamp utilities */
|
|
.line-clamp-2 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.line-clamp-none {
|
|
display: block;
|
|
-webkit-line-clamp: unset;
|
|
}
|
|
</style>
|