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

379 lines
9.1 KiB
Vue

<template>
<div class="manual-outcome-entry">
<div class="entry-container">
<!-- Header -->
<div class="entry-header">
<h3 class="text-lg font-bold text-gray-900">Select Outcome</h3>
<div class="text-sm text-gray-600">
Read your player card and select the result
</div>
</div>
<!-- Outcome Selection -->
<div class="outcome-section">
<label class="form-label">Outcome Type</label>
<!-- Outcome Categories -->
<div class="outcome-categories">
<div
v-for="category in outcomeCategories"
:key="category.name"
class="outcome-category"
>
<div class="category-header">{{ category.name }}</div>
<div class="category-buttons">
<button
v-for="outcome in category.outcomes"
:key="outcome"
:class="[
'outcome-button',
selectedOutcome === outcome ? 'outcome-button-selected' : 'outcome-button-default'
]"
@click="selectOutcome(outcome)"
>
{{ formatOutcome(outcome) }}
</button>
</div>
</div>
</div>
</div>
<!-- Hit Location (conditional) -->
<div v-if="needsHitLocation" class="hit-location-section">
<label class="form-label">Hit Location</label>
<div class="location-grid">
<div class="location-group">
<div class="location-group-label">Infield</div>
<div class="location-buttons">
<button
v-for="position in infieldPositions"
:key="position"
:class="[
'location-button',
selectedHitLocation === position ? 'location-button-selected' : 'location-button-default'
]"
@click="selectHitLocation(position)"
>
{{ position }}
</button>
</div>
</div>
<div class="location-group">
<div class="location-group-label">Outfield</div>
<div class="location-buttons">
<button
v-for="position in outfieldPositions"
:key="position"
:class="[
'location-button',
selectedHitLocation === position ? 'location-button-selected' : 'location-button-default'
]"
@click="selectHitLocation(position)"
>
{{ position }}
</button>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<button
class="button button-cancel"
@click="handleCancel"
>
Cancel
</button>
<button
:disabled="!canSubmitForm"
:class="[
'button',
canSubmitForm ? 'button-submit' : 'button-submit-disabled'
]"
@click="handleSubmit"
>
Submit Outcome
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { PlayOutcome, RollData } from '~/types'
interface Props {
rollData: RollData | null
canSubmit: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
submit: [{ outcome: PlayOutcome; hitLocation?: string }]
cancel: []
}>()
// Local state
const selectedOutcome = ref<PlayOutcome | null>(null)
const selectedHitLocation = ref<string | null>(null)
// Outcome categories
const outcomeCategories = [
{
name: 'Outs',
outcomes: ['STRIKEOUT', 'GROUNDOUT', 'FLYOUT', 'LINEOUT', 'POPOUT', 'DOUBLE_PLAY'] as PlayOutcome[],
},
{
name: 'Hits',
outcomes: ['SINGLE_1', 'SINGLE_2', 'SINGLE_UNCAPPED', 'DOUBLE_2', 'DOUBLE_3', 'DOUBLE_UNCAPPED', 'TRIPLE', 'HOMERUN'] as PlayOutcome[],
},
{
name: 'Walks / HBP',
outcomes: ['WALK', 'INTENTIONAL_WALK', 'HIT_BY_PITCH'] as PlayOutcome[],
},
{
name: 'Special',
outcomes: ['ERROR'] as PlayOutcome[],
},
{
name: 'Interrupts',
outcomes: ['STOLEN_BASE', 'CAUGHT_STEALING', 'WILD_PITCH', 'PASSED_BALL', 'BALK', 'PICK_OFF'] as PlayOutcome[],
},
]
// Hit location options
const infieldPositions = ['P', 'C', '1B', '2B', '3B', 'SS']
const outfieldPositions = ['LF', 'CF', 'RF']
// Outcomes that require hit location
const outcomesNeedingHitLocation = [ // TODO: Remove unneeded outcomes
'GROUNDOUT',
'FLYOUT',
'LINEOUT',
'SINGLE_1',
'SINGLE_2',
'SINGLE_UNCAPPED',
'DOUBLE_2',
'DOUBLE_3',
'DOUBLE_UNCAPPED',
'TRIPLE',
'ERROR',
]
// Computed
const needsHitLocation = computed(() => {
return selectedOutcome.value !== null && outcomesNeedingHitLocation.includes(selectedOutcome.value)
})
const canSubmitForm = computed(() => {
if (!selectedOutcome.value) return false
if (needsHitLocation.value && !selectedHitLocation.value) return false
return props.canSubmit && props.rollData !== null
})
// Methods
const selectOutcome = (outcome: PlayOutcome) => {
selectedOutcome.value = outcome
// Clear hit location if the new outcome doesn't need it
if (!outcomesNeedingHitLocation.includes(outcome)) {
selectedHitLocation.value = null
}
}
const selectHitLocation = (location: string) => {
selectedHitLocation.value = location
}
const handleSubmit = () => {
if (!canSubmitForm.value || !selectedOutcome.value) return
emit('submit', {
outcome: selectedOutcome.value,
hitLocation: selectedHitLocation.value || undefined,
})
// Reset form
selectedOutcome.value = null
selectedHitLocation.value = null
}
const handleCancel = () => {
selectedOutcome.value = null
selectedHitLocation.value = null
emit('cancel')
}
const formatOutcome = (outcome: string): string => {
// Convert outcome to readable format
return outcome
.split('_')
.map(word => word.charAt(0) + word.slice(1).toLowerCase())
.join(' ')
}
</script>
<style scoped>
.manual-outcome-entry {
@apply w-full;
}
.entry-container {
@apply bg-white rounded-xl shadow-lg p-6 space-y-6;
}
/* Header */
.entry-header {
@apply pb-4 border-b border-gray-200;
}
/* Form Sections */
.outcome-section,
.hit-location-section {
@apply space-y-3;
}
.form-label {
@apply block text-sm font-semibold text-gray-700 uppercase tracking-wide mb-2;
}
/* Outcome Categories */
.outcome-categories {
@apply space-y-4;
}
.outcome-category {
@apply space-y-2;
}
.category-header {
@apply text-xs font-bold text-gray-600 uppercase tracking-wider;
}
.category-buttons {
@apply flex flex-wrap gap-2;
}
/* Outcome Buttons */
.outcome-button {
@apply px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150;
@apply border-2 min-h-[44px];
}
.outcome-button-default {
@apply bg-white border-gray-300 text-gray-700;
@apply hover:border-blue-400 hover:bg-blue-50;
}
.outcome-button-selected {
@apply bg-gradient-to-r from-blue-500 to-blue-600 border-blue-600 text-white;
@apply shadow-md;
}
/* Hit Location Grid */
.location-grid {
@apply grid grid-cols-1 gap-4;
}
@media (min-width: 640px) {
.location-grid {
@apply grid-cols-2;
}
}
.location-group {
@apply space-y-2;
}
.location-group-label {
@apply text-xs font-bold text-gray-600 uppercase tracking-wider;
}
.location-buttons {
@apply grid grid-cols-3 gap-2;
}
/* Location Buttons */
.location-button {
@apply px-3 py-3 rounded-lg text-sm font-bold transition-all duration-150;
@apply border-2 min-h-[44px];
}
.location-button-default {
@apply bg-white border-gray-300 text-gray-700;
@apply hover:border-green-400 hover:bg-green-50;
}
.location-button-selected {
@apply bg-gradient-to-r from-green-500 to-green-600 border-green-600 text-white;
@apply shadow-md;
}
/* 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-blue-500 to-blue-600 text-white;
@apply hover:from-blue-600 hover:to-blue-700 hover:shadow-lg;
@apply active:scale-95;
}
.button-submit-disabled {
@apply bg-gray-300 text-gray-500 cursor-not-allowed;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.entry-container {
@apply bg-gray-800 border border-gray-700;
}
.entry-header {
@apply border-gray-700;
}
.entry-header h3,
.form-label {
@apply text-gray-100;
}
.entry-header .text-sm {
@apply text-gray-400;
}
.category-header,
.location-group-label {
@apply text-gray-400;
}
.outcome-button-default,
.location-button-default {
@apply bg-gray-700 border-gray-600 text-gray-200;
@apply hover:border-blue-400 hover:bg-gray-600;
}
.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;
}
}
</style>