strat-gameplay-webapp/frontend-sba/components/Gameplay/PlayResult.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

319 lines
7.6 KiB
Vue

<template>
<div v-if="result" class="play-result" :class="`result-${resultType}`">
<div class="result-container">
<!-- Result Header -->
<div class="result-header">
<div class="result-icon">
<svg v-if="result.is_hit" class="w-8 h-8" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 1.414L10.586 9H7a1 1 0 100 2h3.586l-1.293 1.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414z" clip-rule="evenodd"/>
</svg>
<svg v-else-if="result.is_out" class="w-8 h-8" 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>
<svg v-else class="w-8 h-8" 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>
</div>
<div class="result-title">
Play #{{ result.play_number }}
</div>
</div>
<!-- Result Description -->
<div class="result-description">
{{ result.description }}
</div>
<!-- Result Stats -->
<div class="result-stats">
<div v-if="result.outs_recorded > 0" class="stat-item stat-outs">
<div class="stat-value">{{ result.outs_recorded }}</div>
<div class="stat-label">{{ result.outs_recorded === 1 ? 'Out' : 'Outs' }}</div>
</div>
<div v-if="result.runs_scored > 0" class="stat-item stat-runs">
<div class="stat-value">{{ result.runs_scored }}</div>
<div class="stat-label">{{ result.runs_scored === 1 ? 'Run' : 'Runs' }}</div>
</div>
<div v-if="result.is_hit" class="stat-item stat-hit">
<div class="stat-icon">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
</div>
<div class="stat-label">Hit</div>
</div>
</div>
<!-- Runner Advancement (if any) -->
<div v-if="result.runners_advanced.length > 0" class="runner-advancement">
<div class="advancement-label">Runner Movement:</div>
<div class="advancement-list">
<div
v-for="(advancement, index) in result.runners_advanced"
:key="index"
class="advancement-item"
>
<span class="advancement-from">{{ baseLabel(advancement.from) }}</span>
<svg class="w-4 h-4 mx-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L12.586 11H5a1 1 0 110-2h7.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
<span :class="['advancement-to', advancement.out ? 'advancement-out' : '']">
{{ advancement.out ? 'Out' : baseLabel(advancement.to) }}
</span>
</div>
</div>
</div>
<!-- Dismiss Button (optional) -->
<button
v-if="!autoHide"
class="dismiss-button"
@click="handleDismiss"
>
Dismiss
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import type { PlayResult } from '~/types'
interface Props {
result: PlayResult | null
autoHide?: boolean
}
const props = withDefaults(defineProps<Props>(), {
autoHide: false,
})
const emit = defineEmits<{
dismiss: []
}>()
// Computed
const resultType = computed(() => {
if (!props.result) return 'default'
if (props.result.runs_scored > 0) return 'runs'
if (props.result.is_hit) return 'hit'
if (props.result.is_out) return 'out'
return 'default'
})
// Methods
const baseLabel = (base: number): string => {
if (base === 0) return 'Batter'
if (base === 1) return '1st'
if (base === 2) return '2nd'
if (base === 3) return '3rd'
if (base === 4) return 'Home'
return `Base ${base}`
}
const handleDismiss = () => {
emit('dismiss')
}
// Auto-hide logic
onMounted(() => {
if (props.autoHide && props.result) {
setTimeout(() => {
emit('dismiss')
}, 5000)
}
})
</script>
<style scoped>
.play-result {
@apply w-full;
animation: slideUp 0.4s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.result-container {
@apply bg-white rounded-xl shadow-2xl p-6 space-y-4;
@apply border-l-8;
}
/* Result Types */
.result-runs .result-container {
@apply border-green-500;
}
.result-hit .result-container {
@apply border-blue-500;
}
.result-out .result-container {
@apply border-red-500;
}
.result-default .result-container {
@apply border-gray-400;
}
/* Header */
.result-header {
@apply flex items-center gap-4;
}
.result-icon {
@apply flex-shrink-0;
}
.result-runs .result-icon {
@apply text-green-600;
}
.result-hit .result-icon {
@apply text-blue-600;
}
.result-out .result-icon {
@apply text-red-600;
}
.result-default .result-icon {
@apply text-gray-600;
}
.result-title {
@apply text-lg font-bold text-gray-900;
}
/* Description */
.result-description {
@apply text-xl font-semibold text-gray-800;
@apply leading-relaxed;
}
/* Stats */
.result-stats {
@apply flex gap-4 flex-wrap;
}
.stat-item {
@apply flex items-center gap-2 px-4 py-2 rounded-lg;
}
.stat-outs {
@apply bg-red-100 text-red-800;
}
.stat-runs {
@apply bg-green-100 text-green-800;
}
.stat-hit {
@apply bg-blue-100 text-blue-800;
}
.stat-value {
@apply text-2xl font-bold;
}
.stat-label {
@apply text-sm font-medium uppercase tracking-wide;
}
.stat-icon {
@apply text-blue-600;
}
/* Runner Advancement */
.runner-advancement {
@apply bg-gray-50 rounded-lg p-4 space-y-2;
}
.advancement-label {
@apply text-sm font-semibold text-gray-700 uppercase tracking-wide;
}
.advancement-list {
@apply space-y-1;
}
.advancement-item {
@apply flex items-center text-sm text-gray-700;
}
.advancement-from {
@apply font-medium;
}
.advancement-to {
@apply font-bold text-green-700;
}
.advancement-out {
@apply text-red-700;
}
/* Dismiss Button */
.dismiss-button {
@apply w-full px-6 py-3 bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium rounded-lg;
@apply transition-colors duration-150;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.result-container {
@apply bg-gray-800 border-gray-700;
}
.result-title,
.result-description {
@apply text-gray-100;
}
.stat-outs {
@apply bg-red-900 bg-opacity-50 text-red-200;
}
.stat-runs {
@apply bg-green-900 bg-opacity-50 text-green-200;
}
.stat-hit {
@apply bg-blue-900 bg-opacity-50 text-blue-200;
}
.runner-advancement {
@apply bg-gray-700;
}
.advancement-label {
@apply text-gray-300;
}
.advancement-item {
@apply text-gray-300;
}
.advancement-to {
@apply text-green-400;
}
.advancement-out {
@apply text-red-400;
}
.dismiss-button {
@apply bg-gray-700 hover:bg-gray-600 text-gray-200;
}
}
</style>