strat-gameplay-webapp/frontend-sba/components/Substitutions/PitchingChangeSelector.vue
Cal Corum eab61ad966 CLAUDE: Phases 3.5, F1-F5 Complete - Statistics & Frontend Components
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>
2025-11-14 09:52:30 -06:00

418 lines
10 KiB
Vue

<template>
<div class="pitching-change-selector">
<div class="selector-container">
<!-- Header -->
<div class="selector-header">
<h3 class="selector-title">Pitching Change</h3>
<p class="selector-description">
Bring in a relief pitcher to replace {{ currentPitcher?.player.name || 'current pitcher' }}
</p>
</div>
<!-- Current Pitcher Info -->
<div v-if="currentPitcher" class="current-pitcher">
<div class="pitcher-label">Current Pitcher:</div>
<div class="pitcher-card">
<div class="pitcher-info">
<div class="pitcher-name">{{ currentPitcher.player.name }}</div>
<div class="pitcher-details">
<span v-if="currentPitcher.is_fatigued" class="fatigue-indicator">
<svg class="w-4 h-4 inline" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
Fatigued
</span>
P • Entered Inning {{ currentPitcher.entered_inning }}
</div>
</div>
</div>
</div>
<!-- Available Relievers -->
<div class="relievers-section">
<div class="relievers-label">
Available Relief Pitchers:
</div>
<div v-if="availableRelievers.length === 0" class="no-pitchers">
<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 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>
<div class="no-pitchers-text">No relief pitchers available</div>
</div>
<div v-else class="relievers-grid">
<button
v-for="pitcher in availableRelievers"
:key="pitcher.id"
:class="[
'reliever-card',
selectedPitcherId === pitcher.player.id ? 'reliever-selected' : 'reliever-default',
pitcher.is_fatigued ? 'reliever-fatigued' : ''
]"
:disabled="pitcher.is_fatigued"
@click="selectPitcher(pitcher)"
>
<div class="pitcher-info">
<div class="pitcher-name">{{ pitcher.player.name }}</div>
<div class="pitcher-meta">
<span class="pitcher-role">
{{ getPitcherRole(pitcher) }}
</span>
<span v-if="pitcher.player.wara !== null && pitcher.player.wara !== undefined" class="pitcher-stat">
WARA: {{ pitcher.player.wara.toFixed(1) }}
</span>
<span v-if="pitcher.is_fatigued" class="fatigue-badge">
Fatigued
</span>
</div>
</div>
<div v-if="selectedPitcherId === pitcher.player.id" class="selected-indicator">
<svg class="w-5 h-5" 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>
</div>
</button>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<button
class="button button-cancel"
@click="handleCancel"
>
Cancel
</button>
<button
:disabled="!canSubmit"
:class="[
'button',
canSubmit ? 'button-submit' : 'button-submit-disabled'
]"
@click="handleSubmit"
>
Bring in Reliever
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Lineup } from '~/types'
interface Props {
currentPitcher: Lineup | null
benchPlayers: Lineup[]
teamId: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
submit: [{ playerOutLineupId: number; playerInCardId: number; teamId: number }]
cancel: []
}>()
// Local state
const selectedPitcherId = ref<number | null>(null)
// Computed
const availableRelievers = computed(() => {
// Filter bench players who can pitch
return props.benchPlayers.filter(player => {
if (player.is_active) return false
// Check if player can pitch (has P in any position slot)
const positions = getPlayerPositions(player.player)
return positions.includes('P')
})
})
const selectedPitcher = computed(() => {
if (!selectedPitcherId.value) return null
return availableRelievers.value.find(p => p.player.id === selectedPitcherId.value)
})
const canSubmit = computed(() => {
return (
props.currentPitcher !== null &&
selectedPitcher.value !== null &&
!selectedPitcher.value.is_fatigued
)
})
// Methods
const getPlayerPositions = (player: any): string[] => {
const positions: string[] = []
for (let i = 1; i <= 8; i++) {
const pos = player[`pos_${i}`]
if (pos) positions.push(pos)
}
return positions
}
const getPitcherRole = (pitcher: Lineup): string => {
const positions = getPlayerPositions(pitcher.player)
if (positions[0] === 'P') return 'Pitcher'
return 'Relief Pitcher'
}
const selectPitcher = (pitcher: Lineup) => {
if (pitcher.is_fatigued) return
selectedPitcherId.value = pitcher.player.id
}
const handleSubmit = () => {
if (!canSubmit.value || !props.currentPitcher || !selectedPitcher.value) {
return
}
emit('submit', {
playerOutLineupId: props.currentPitcher.id,
playerInCardId: selectedPitcher.value.player.id,
teamId: props.teamId,
})
// Reset selection
selectedPitcherId.value = null
}
const handleCancel = () => {
selectedPitcherId.value = null
emit('cancel')
}
</script>
<style scoped>
.pitching-change-selector {
@apply w-full;
}
.selector-container {
@apply bg-white rounded-xl shadow-lg p-6 space-y-6;
}
/* Header */
.selector-header {
@apply pb-4 border-b border-gray-200;
}
.selector-title {
@apply text-xl font-bold text-gray-900 mb-1;
}
.selector-description {
@apply text-sm text-gray-600;
}
/* Current Pitcher */
.current-pitcher {
@apply bg-red-50 border border-red-200 rounded-lg p-4;
}
.pitcher-label {
@apply text-xs font-semibold text-red-700 uppercase tracking-wide mb-2;
}
.pitcher-card {
@apply bg-white rounded-lg p-3 shadow-sm;
}
.pitcher-info {
@apply space-y-1;
}
.pitcher-name {
@apply font-bold text-gray-900;
}
.pitcher-details,
.pitcher-meta {
@apply text-sm text-gray-600 flex items-center gap-2;
}
.fatigue-indicator {
@apply inline-flex items-center gap-1 text-yellow-700 font-semibold;
}
.pitcher-role {
@apply text-blue-600 font-medium;
}
.pitcher-stat {
@apply text-gray-500;
}
.fatigue-badge {
@apply text-xs px-2 py-0.5 bg-yellow-100 text-yellow-800 rounded-full font-semibold;
}
/* Relievers Section */
.relievers-section {
@apply space-y-3;
}
.relievers-label {
@apply text-sm font-semibold text-gray-700 uppercase tracking-wide;
}
.no-pitchers {
@apply flex flex-col items-center justify-center py-12 text-gray-400;
}
.no-pitchers-text {
@apply mt-3 text-sm font-medium;
}
.relievers-grid {
@apply grid grid-cols-1 gap-3;
}
@media (min-width: 640px) {
.relievers-grid {
@apply grid-cols-2;
}
}
/* Reliever Cards */
.reliever-card {
@apply relative p-4 rounded-lg border-2 transition-all duration-150;
@apply text-left min-h-[80px];
}
.reliever-default {
@apply bg-white border-gray-300 hover:border-green-400 hover:bg-green-50;
}
.reliever-selected {
@apply bg-gradient-to-br from-green-50 to-green-100 border-green-500 shadow-md;
}
.reliever-fatigued {
@apply opacity-50 cursor-not-allowed;
@apply border-gray-200 hover:border-gray-200 hover:bg-white;
}
.selected-indicator {
@apply absolute top-2 right-2 text-green-600;
}
/* Action Buttons */
.action-buttons {
@apply flex gap-3 pt-4 border-t border-gray-200;
}
.button {
@apply flex-1 px-6 py-3 rounded-lg font-bold text-base transition-all duration-200;
@apply shadow-md min-h-[52px];
}
.button-cancel {
@apply bg-white border-2 border-gray-300 text-gray-700;
@apply hover:border-gray-400 hover:bg-gray-50;
}
.button-submit {
@apply bg-gradient-to-r from-green-500 to-green-600 text-white;
@apply hover:from-green-600 hover:to-green-700 hover:shadow-lg;
@apply active:scale-95;
}
.button-submit-disabled {
@apply bg-gray-300 text-gray-500 cursor-not-allowed;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.selector-container {
@apply bg-gray-800 border border-gray-700;
}
.selector-header {
@apply border-gray-700;
}
.selector-title {
@apply text-gray-100;
}
.selector-description {
@apply text-gray-400;
}
.current-pitcher {
@apply bg-red-900 bg-opacity-20 border-red-700;
}
.pitcher-label {
@apply text-red-400;
}
.pitcher-card {
@apply bg-gray-700;
}
.pitcher-name {
@apply text-gray-100;
}
.pitcher-details,
.pitcher-meta {
@apply text-gray-400;
}
.relievers-label {
@apply text-gray-300;
}
.reliever-default {
@apply bg-gray-700 border-gray-600 text-gray-200;
@apply hover:border-green-400 hover:bg-gray-600;
}
.reliever-fatigued {
@apply bg-gray-800 border-gray-700;
@apply hover:bg-gray-800 hover:border-gray-700;
}
.button-cancel {
@apply bg-gray-700 border-gray-600 text-gray-200;
@apply hover:border-gray-500 hover:bg-gray-600;
}
.action-buttons {
@apply border-gray-700;
}
.fatigue-badge {
@apply bg-yellow-900 bg-opacity-50 text-yellow-400;
}
}
/* Mobile optimizations */
@media (max-width: 640px) {
.selector-container {
@apply p-4;
}
.relievers-grid {
@apply gap-2;
}
.reliever-card {
@apply p-3 min-h-[70px];
}
.action-buttons {
@apply flex-col gap-2;
}
.button {
@apply w-full;
}
}
</style>