This commit captures work from multiple sessions building the statistics system and frontend component library. Backend - Phase 3.5: Statistics System - Box score statistics with materialized views - Play stat calculator for real-time updates - Stat view refresher service - Alembic migration for materialized views - Test coverage: 41 new tests (all passing) Frontend - Phase F1: Foundation - Composables: useGameState, useGameActions, useWebSocket - Type definitions and interfaces - Store setup with Pinia Frontend - Phase F2: Game Display - ScoreBoard, GameBoard, CurrentSituation, PlayByPlay components - Demo page at /demo Frontend - Phase F3: Decision Inputs - DefensiveSetup, OffensiveApproach, StolenBaseInputs components - DecisionPanel orchestration - Demo page at /demo-decisions - Test coverage: 213 tests passing Frontend - Phase F4: Dice & Manual Outcome - DiceRoller component - ManualOutcomeEntry with validation - PlayResult display - GameplayPanel orchestration - Demo page at /demo-gameplay - Test coverage: 119 tests passing Frontend - Phase F5: Substitutions - PinchHitterSelector, DefensiveReplacementSelector, PitchingChangeSelector - SubstitutionPanel with tab navigation - Demo page at /demo-substitutions - Test coverage: 114 tests passing Documentation: - PHASE_3_5_HANDOFF.md - Statistics system handoff - PHASE_F2_COMPLETE.md - Game display completion - Frontend phase planning docs - NEXT_SESSION.md updated for Phase F6 Configuration: - Package updates (Nuxt 4 fixes) - Tailwind config enhancements - Game store updates Test Status: - Backend: 731/731 passing (100%) - Frontend: 446/446 passing (100%) - Total: 1,177 tests passing Next Phase: F6 - Integration (wire all components into game page) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
531 lines
14 KiB
Vue
531 lines
14 KiB
Vue
<template>
|
|
<div class="substitution-panel">
|
|
<div class="panel-container">
|
|
<!-- Panel Header -->
|
|
<div class="panel-header">
|
|
<h2 class="panel-title">Substitutions</h2>
|
|
<div class="panel-status">
|
|
<div :class="['status-indicator', statusClass]"></div>
|
|
<span class="status-text">{{ statusText }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab Navigation -->
|
|
<div class="tab-navigation">
|
|
<button
|
|
v-for="tab in tabs"
|
|
:key="tab.type"
|
|
:class="['tab-button', currentTab === tab.type ? 'tab-active' : 'tab-inactive']"
|
|
@click="selectTab(tab.type)"
|
|
>
|
|
<span class="tab-icon">{{ tab.icon }}</span>
|
|
<span class="tab-label">{{ tab.label }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Panel Content -->
|
|
<div class="panel-content">
|
|
<!-- No Selection State -->
|
|
<div v-if="!currentTab" class="state-idle">
|
|
<div class="state-message">
|
|
<svg class="w-12 h-12 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<div class="message-text">
|
|
Select a substitution type above
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pinch Hitter -->
|
|
<template v-else-if="currentTab === 'pinch_hitter'">
|
|
<div class="instruction-box">
|
|
<svg class="w-6 h-6 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<span>Select a bench player to bat in place of the current batter</span>
|
|
</div>
|
|
|
|
<PinchHitterSelector
|
|
:player-out="currentBatter"
|
|
:bench-players="benchPlayers"
|
|
:team-id="teamId"
|
|
@submit="handlePinchHitterSubmit"
|
|
@cancel="handleCancel"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Defensive Replacement -->
|
|
<template v-else-if="currentTab === 'defensive_replacement'">
|
|
<div class="instruction-box">
|
|
<svg class="w-6 h-6 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<span>Replace a defensive player with a bench player</span>
|
|
</div>
|
|
|
|
<DefensiveReplacementSelector
|
|
:player-out="selectedDefensivePlayer"
|
|
:bench-players="benchPlayers"
|
|
:team-id="teamId"
|
|
@submit="handleDefensiveReplacementSubmit"
|
|
@cancel="handleCancel"
|
|
/>
|
|
|
|
<!-- Player Selection (if no player selected) -->
|
|
<div v-if="!selectedDefensivePlayer" class="player-selection">
|
|
<div class="selection-label">Select player to replace:</div>
|
|
<div class="player-grid">
|
|
<button
|
|
v-for="player in activeFielders"
|
|
:key="player.id"
|
|
class="player-button"
|
|
@click="selectDefensivePlayer(player)"
|
|
>
|
|
<div class="player-name">{{ player.player.name }}</div>
|
|
<div class="player-position">{{ player.position }}</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Pitching Change -->
|
|
<template v-else-if="currentTab === 'pitching_change'">
|
|
<div class="instruction-box">
|
|
<svg class="w-6 h-6 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<span>Bring in a relief pitcher from the bench</span>
|
|
</div>
|
|
|
|
<PitchingChangeSelector
|
|
:current-pitcher="currentPitcher"
|
|
:bench-players="benchPlayers"
|
|
:team-id="teamId"
|
|
@submit="handlePitchingChangeSubmit"
|
|
@cancel="handleCancel"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Error State -->
|
|
<div v-if="error" class="error-message">
|
|
<svg class="w-6 h-6 text-red-600" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<span>{{ error }}</span>
|
|
</div>
|
|
|
|
<!-- Success State -->
|
|
<div v-if="success" class="success-message">
|
|
<svg class="w-6 h-6 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<span>{{ success }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import type { Lineup, SubstitutionType } from '~/types'
|
|
import PinchHitterSelector from './PinchHitterSelector.vue'
|
|
import DefensiveReplacementSelector from './DefensiveReplacementSelector.vue'
|
|
import PitchingChangeSelector from './PitchingChangeSelector.vue'
|
|
|
|
interface TabDefinition {
|
|
type: SubstitutionType
|
|
label: string
|
|
icon: string
|
|
}
|
|
|
|
interface Props {
|
|
gameId: string
|
|
teamId: number
|
|
currentLineup: Lineup[]
|
|
benchPlayers: Lineup[]
|
|
currentPitcher: Lineup | null
|
|
currentBatter: Lineup | null
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
const emit = defineEmits<{
|
|
pinchHitter: [{ playerOutLineupId: number; playerInCardId: number; teamId: number }]
|
|
defensiveReplacement: [{ playerOutLineupId: number; playerInCardId: number; newPosition: string; teamId: number }]
|
|
pitchingChange: [{ playerOutLineupId: number; playerInCardId: number; teamId: number }]
|
|
cancel: []
|
|
}>()
|
|
|
|
// Tab definitions
|
|
const tabs: TabDefinition[] = [
|
|
{ type: 'pinch_hitter', label: 'Pinch Hitter', icon: '🏏' },
|
|
{ type: 'defensive_replacement', label: 'Defensive Sub', icon: '🧤' },
|
|
{ type: 'pitching_change', label: 'Pitching Change', icon: '⚾' },
|
|
]
|
|
|
|
// Local state
|
|
const currentTab = ref<SubstitutionType | null>(null)
|
|
const selectedDefensivePlayer = ref<Lineup | null>(null)
|
|
const error = ref<string | null>(null)
|
|
const success = ref<string | null>(null)
|
|
|
|
// Computed
|
|
const activeFielders = computed(() => {
|
|
return props.currentLineup.filter(p => p.is_active && p.position !== 'P')
|
|
})
|
|
|
|
const statusClass = computed(() => {
|
|
if (error.value) return 'status-error'
|
|
if (success.value) return 'status-success'
|
|
if (currentTab.value) return 'status-active'
|
|
return 'status-idle'
|
|
})
|
|
|
|
const statusText = computed(() => {
|
|
if (error.value) return 'Error'
|
|
if (success.value) return 'Success'
|
|
if (currentTab.value) return 'Active'
|
|
return 'Idle'
|
|
})
|
|
|
|
// Methods
|
|
const selectTab = (type: SubstitutionType) => {
|
|
currentTab.value = type
|
|
error.value = null
|
|
success.value = null
|
|
|
|
// Reset defensive player selection when switching tabs
|
|
if (type !== 'defensive_replacement') {
|
|
selectedDefensivePlayer.value = null
|
|
}
|
|
}
|
|
|
|
const selectDefensivePlayer = (player: Lineup) => {
|
|
selectedDefensivePlayer.value = player
|
|
}
|
|
|
|
const handlePinchHitterSubmit = (payload: { playerOutLineupId: number; playerInCardId: number; teamId: number }) => {
|
|
error.value = null
|
|
success.value = null
|
|
|
|
try {
|
|
emit('pinchHitter', payload)
|
|
success.value = 'Pinch hitter substitution submitted'
|
|
|
|
// Reset after short delay
|
|
setTimeout(() => {
|
|
success.value = null
|
|
currentTab.value = null
|
|
}, 2000)
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'Failed to submit substitution'
|
|
}
|
|
}
|
|
|
|
const handleDefensiveReplacementSubmit = (payload: { playerOutLineupId: number; playerInCardId: number; newPosition: string; teamId: number }) => {
|
|
error.value = null
|
|
success.value = null
|
|
|
|
try {
|
|
emit('defensiveReplacement', payload)
|
|
success.value = 'Defensive replacement submitted'
|
|
|
|
// Reset after short delay
|
|
setTimeout(() => {
|
|
success.value = null
|
|
currentTab.value = null
|
|
selectedDefensivePlayer.value = null
|
|
}, 2000)
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'Failed to submit substitution'
|
|
}
|
|
}
|
|
|
|
const handlePitchingChangeSubmit = (payload: { playerOutLineupId: number; playerInCardId: number; teamId: number }) => {
|
|
error.value = null
|
|
success.value = null
|
|
|
|
try {
|
|
emit('pitchingChange', payload)
|
|
success.value = 'Pitching change submitted'
|
|
|
|
// Reset after short delay
|
|
setTimeout(() => {
|
|
success.value = null
|
|
currentTab.value = null
|
|
}, 2000)
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'Failed to submit substitution'
|
|
}
|
|
}
|
|
|
|
const handleCancel = () => {
|
|
error.value = null
|
|
success.value = null
|
|
currentTab.value = null
|
|
selectedDefensivePlayer.value = null
|
|
emit('cancel')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.substitution-panel {
|
|
@apply w-full;
|
|
}
|
|
|
|
.panel-container {
|
|
@apply bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl shadow-lg;
|
|
@apply border border-gray-200;
|
|
}
|
|
|
|
/* Header */
|
|
.panel-header {
|
|
@apply flex justify-between items-center px-6 py-4 border-b border-gray-200;
|
|
@apply bg-white rounded-t-xl;
|
|
}
|
|
|
|
.panel-title {
|
|
@apply text-xl font-bold text-gray-900;
|
|
}
|
|
|
|
.panel-status {
|
|
@apply flex items-center gap-2;
|
|
}
|
|
|
|
.status-indicator {
|
|
@apply w-3 h-3 rounded-full;
|
|
}
|
|
|
|
.status-idle {
|
|
@apply bg-gray-400;
|
|
}
|
|
|
|
.status-active {
|
|
@apply bg-blue-500 animate-pulse;
|
|
}
|
|
|
|
.status-success {
|
|
@apply bg-green-500;
|
|
}
|
|
|
|
.status-error {
|
|
@apply bg-red-500;
|
|
}
|
|
|
|
.status-text {
|
|
@apply text-sm font-medium text-gray-700;
|
|
}
|
|
|
|
/* Tab Navigation */
|
|
.tab-navigation {
|
|
@apply flex border-b border-gray-200 bg-white px-2;
|
|
}
|
|
|
|
.tab-button {
|
|
@apply flex items-center gap-2 px-4 py-3 font-semibold transition-all duration-200;
|
|
@apply border-b-2 -mb-px;
|
|
}
|
|
|
|
.tab-inactive {
|
|
@apply text-gray-600 border-transparent;
|
|
@apply hover:text-gray-900 hover:border-gray-300;
|
|
}
|
|
|
|
.tab-active {
|
|
@apply text-blue-600 border-blue-600;
|
|
}
|
|
|
|
.tab-icon {
|
|
@apply text-xl;
|
|
}
|
|
|
|
.tab-label {
|
|
@apply text-sm;
|
|
}
|
|
|
|
/* Content */
|
|
.panel-content {
|
|
@apply p-6 space-y-4;
|
|
}
|
|
|
|
/* State Messages */
|
|
.state-message {
|
|
@apply flex flex-col items-center justify-center py-12 space-y-4;
|
|
}
|
|
|
|
.message-text {
|
|
@apply text-lg text-gray-600 text-center;
|
|
}
|
|
|
|
/* Instruction Box */
|
|
.instruction-box {
|
|
@apply flex items-center gap-3 p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4;
|
|
}
|
|
|
|
.instruction-box span {
|
|
@apply text-sm font-medium text-blue-800;
|
|
}
|
|
|
|
/* Player Selection (for defensive replacement) */
|
|
.player-selection {
|
|
@apply mt-6 space-y-3;
|
|
}
|
|
|
|
.selection-label {
|
|
@apply text-sm font-semibold text-gray-700 uppercase tracking-wide;
|
|
}
|
|
|
|
.player-grid {
|
|
@apply grid grid-cols-2 gap-3;
|
|
}
|
|
|
|
@media (min-width: 640px) {
|
|
.player-grid {
|
|
@apply grid-cols-3;
|
|
}
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
.player-grid {
|
|
@apply grid-cols-4;
|
|
}
|
|
}
|
|
|
|
.player-button {
|
|
@apply p-3 bg-white border-2 border-gray-300 rounded-lg;
|
|
@apply hover:border-blue-400 hover:bg-blue-50;
|
|
@apply transition-all duration-150;
|
|
@apply text-left min-h-[70px];
|
|
}
|
|
|
|
.player-name {
|
|
@apply font-bold text-gray-900 text-sm;
|
|
}
|
|
|
|
.player-position {
|
|
@apply text-xs text-gray-600 mt-1;
|
|
}
|
|
|
|
/* Error Message */
|
|
.error-message {
|
|
@apply flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg;
|
|
@apply text-red-800 font-medium;
|
|
}
|
|
|
|
/* Success Message */
|
|
.success-message {
|
|
@apply flex items-center gap-3 p-4 bg-green-50 border border-green-200 rounded-lg;
|
|
@apply text-green-800 font-medium;
|
|
}
|
|
|
|
/* Dark mode support */
|
|
@media (prefers-color-scheme: dark) {
|
|
.panel-container {
|
|
@apply from-gray-800 to-gray-900 border-gray-700;
|
|
}
|
|
|
|
.panel-header {
|
|
@apply bg-gray-800 border-gray-700;
|
|
}
|
|
|
|
.panel-title {
|
|
@apply text-gray-100;
|
|
}
|
|
|
|
.status-text {
|
|
@apply text-gray-300;
|
|
}
|
|
|
|
.tab-navigation {
|
|
@apply bg-gray-800 border-gray-700;
|
|
}
|
|
|
|
.tab-inactive {
|
|
@apply text-gray-400;
|
|
@apply hover:text-gray-200 hover:border-gray-600;
|
|
}
|
|
|
|
.tab-active {
|
|
@apply text-blue-400 border-blue-400;
|
|
}
|
|
|
|
.message-text {
|
|
@apply text-gray-400;
|
|
}
|
|
|
|
.instruction-box {
|
|
@apply bg-blue-900 bg-opacity-30 border-blue-700;
|
|
}
|
|
|
|
.instruction-box span {
|
|
@apply text-blue-300;
|
|
}
|
|
|
|
.selection-label {
|
|
@apply text-gray-300;
|
|
}
|
|
|
|
.player-button {
|
|
@apply bg-gray-700 border-gray-600 text-gray-200;
|
|
@apply hover:border-blue-400 hover:bg-gray-600;
|
|
}
|
|
|
|
.player-name {
|
|
@apply text-gray-100;
|
|
}
|
|
|
|
.player-position {
|
|
@apply text-gray-400;
|
|
}
|
|
|
|
.error-message {
|
|
@apply bg-red-900 bg-opacity-30 border-red-700 text-red-300;
|
|
}
|
|
|
|
.success-message {
|
|
@apply bg-green-900 bg-opacity-30 border-green-700 text-green-300;
|
|
}
|
|
}
|
|
|
|
/* Mobile optimizations */
|
|
@media (max-width: 640px) {
|
|
.panel-header {
|
|
@apply px-4 py-3;
|
|
}
|
|
|
|
.panel-title {
|
|
@apply text-lg;
|
|
}
|
|
|
|
.panel-content {
|
|
@apply p-4;
|
|
}
|
|
|
|
.state-message {
|
|
@apply py-8;
|
|
}
|
|
|
|
.message-text {
|
|
@apply text-base;
|
|
}
|
|
|
|
.tab-navigation {
|
|
@apply px-1 overflow-x-auto;
|
|
}
|
|
|
|
.tab-button {
|
|
@apply px-3 py-2;
|
|
}
|
|
|
|
.tab-label {
|
|
@apply text-xs;
|
|
}
|
|
|
|
.player-grid {
|
|
@apply grid-cols-1;
|
|
}
|
|
}
|
|
</style>
|