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>
319 lines
7.6 KiB
Vue
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>
|