strat-gameplay-webapp/frontend-sba/components/Lineup/UnifiedLineupTab.vue
Cal Corum e058bc4a6c CLAUDE: RosterLink refactor for bench players with cached player data
- Add player_positions JSONB column to roster_links (migration 006)
- Add player_data JSONB column to cache name/image/headshot (migration 007)
- Add is_pitcher/is_batter computed properties for two-way player support
- Update lineup submission to populate RosterLink with all players + positions
- Update get_bench handler to use cached data (no runtime API calls)
- Add BenchPlayer type to frontend with proper filtering
- Add new Lineup components: InlineSubstitutionPanel, LineupSlotRow,
  PositionSelector, UnifiedLineupTab
- Add integration tests for get_bench_players

Bench players now load instantly without API dependency, and properly
filter batters vs pitchers (including CP closer position).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:15:12 -06:00

427 lines
14 KiB
Vue

<template>
<div class="unified-lineup-tab">
<!-- Pre-game: Show LineupBuilder -->
<template v-if="!isGameActive">
<LineupBuilder
:game-id="gameId"
:team-id="myTeamId"
@lineups-submitted="handleLineupSubmit"
/>
</template>
<!-- Mid-game: Read-only lineup with substitution actions -->
<template v-else>
<!-- Team Tabs -->
<div class="flex gap-2 mb-4 bg-gray-800/50 p-1 rounded-xl inline-flex">
<button
:class="[
'px-4 py-2 text-sm font-semibold rounded-lg transition-colors select-none',
isViewingAwayTeam ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-700/50'
]"
@click="viewingTeamId = awayTeamId"
>
{{ awayTeamName }}
</button>
<button
:class="[
'px-4 py-2 text-sm font-semibold rounded-lg transition-colors select-none',
!isViewingAwayTeam ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-700/50'
]"
@click="viewingTeamId = homeTeamId"
>
{{ homeTeamName }}
</button>
</div>
<!-- Context indicator -->
<p class="text-xs text-gray-400 mb-4">
Mid-game view
<template v-if="isMyTeam">
Your team is {{ isBatting ? 'BATTING' : 'FIELDING' }}
</template>
</p>
<!-- Batting Order Section -->
<div class="mb-4">
<h2 class="text-sm font-semibold text-gray-400 mb-2 uppercase tracking-wide">Batting Order</h2>
<LineupSlotRow
v-for="slot in battingOrderSlots"
:key="slot.lineup_id"
:player="slot"
:batting-order="slot.batting_order"
:state="getSlotState(slot)"
:is-expanded="expandedSlotId === slot.lineup_id && expandedMode !== 'position_change'"
:show-actions="canSubstitute(slot)"
:show-position-change="canChangePosition(slot)"
:substitute-label="getSubstituteLabel(slot)"
@substitute="openSubstitution(slot)"
@change-position="openPositionChange(slot)"
@cancel="closeExpanded"
>
<template #expanded>
<InlineSubstitutionPanel
:substitution-type="getSubstitutionType(slot)"
:bench-players="benchPlayers"
:current-position="slot.position"
:team-id="viewingTeamId"
:player-out-lineup-id="slot.lineup_id"
@submit="handleSubstitutionSubmit"
/>
</template>
</LineupSlotRow>
</div>
<!-- Runners on Base Section (when batting) -->
<div v-if="isBatting && runnersOnBase.length > 0" class="mb-4">
<h2 class="text-sm font-semibold text-green-400 mb-2 uppercase tracking-wide flex items-center gap-2">
<span>◆</span> Runners on Base
</h2>
<div class="bg-green-900/20 rounded-lg border border-green-700/30 p-2.5 space-y-2">
<LineupSlotRow
v-for="runner in runnersOnBase"
:key="`runner-${runner.lineup_id}`"
:player="runner"
:base-position="getBasePosition(runner)"
:state="'on_base'"
:is-expanded="expandedSlotId === runner.lineup_id && expandedMode === 'pinch_runner'"
:show-actions="canSubstitute(runner)"
:show-position-change="false"
substitute-label="Pinch Run"
@substitute="openPinchRunner(runner)"
@cancel="closeExpanded"
>
<template #expanded>
<InlineSubstitutionPanel
substitution-type="pinch_runner"
:bench-players="benchPlayers"
:current-position="runner.position"
:team-id="viewingTeamId"
:player-out-lineup-id="runner.lineup_id"
@submit="handleSubstitutionSubmit"
/>
</template>
</LineupSlotRow>
</div>
</div>
<!-- Pitcher Section -->
<div class="mb-4">
<h2 class="text-sm font-semibold text-gray-400 mb-2 uppercase tracking-wide">
{{ isStartingPitcher ? 'Starting Pitcher' : 'Current Pitcher' }}
</h2>
<LineupSlotRow
v-if="currentPitcherLineup"
:player="currentPitcherLineup"
:is-pitcher="true"
:state="expandedSlotId === currentPitcherLineup.lineup_id ? 'expanded' : 'normal'"
:is-expanded="expandedSlotId === currentPitcherLineup.lineup_id"
:show-actions="canChangePitcher"
:show-position-change="false"
substitute-label="Change Pitcher"
@substitute="openPitchingChange"
@cancel="closeExpanded"
>
<template #expanded>
<InlineSubstitutionPanel
substitution-type="relief_pitcher"
:bench-players="benchPlayers"
:current-position="'P'"
:team-id="viewingTeamId"
:player-out-lineup-id="currentPitcherLineup.lineup_id"
@submit="handleSubstitutionSubmit"
/>
</template>
</LineupSlotRow>
</div>
<!-- Position Change Panel (separate from substitution) -->
<div v-if="expandedMode === 'position_change' && positionChangeSlot" class="mb-4">
<h2 class="text-sm font-semibold text-blue-400 mb-2 uppercase tracking-wide">Position Change</h2>
<LineupSlotRow
:player="positionChangeSlot"
:state="'position_change'"
:is-expanded="true"
:show-actions="false"
@cancel="closeExpanded"
>
<template #expanded>
<InlineSubstitutionPanel
substitution-type="position_change"
:bench-players="[]"
:current-position="positionChangeSlot.position"
:team-id="viewingTeamId"
:player-out-lineup-id="positionChangeSlot.lineup_id"
@submit="handleSubstitutionSubmit"
/>
</template>
</LineupSlotRow>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useGameStore } from '~/store/game'
import { useGameActions } from '~/composables/useGameActions'
import type { Lineup } from '~/types'
import LineupBuilder from '~/components/Game/LineupBuilder.vue'
import LineupSlotRow from './LineupSlotRow.vue'
import InlineSubstitutionPanel from './InlineSubstitutionPanel.vue'
type ExpandedMode = 'substitution' | 'position_change' | 'pinch_runner' | null
interface Props {
gameId: string
myTeamId: number | null
homeTeamName?: string
awayTeamName?: string
}
const props = withDefaults(defineProps<Props>(), {
homeTeamName: 'Home',
awayTeamName: 'Away',
})
const emit = defineEmits<{
lineupsSubmitted: [result: unknown]
}>()
const gameStore = useGameStore()
const { submitSubstitution, getBench } = useGameActions()
// Local state
const viewingTeamId = ref<number>(props.myTeamId ?? gameStore.gameState?.away_team_id ?? 0)
const expandedSlotId = ref<number | null>(null)
const expandedMode = ref<ExpandedMode>(null)
// Computed - Game state
const isGameActive = computed(() => gameStore.gameStatus === 'active')
const homeTeamId = computed(() => gameStore.gameState?.home_team_id ?? 0)
const awayTeamId = computed(() => gameStore.gameState?.away_team_id ?? 0)
// Computed - Team viewing
const isViewingAwayTeam = computed(() => viewingTeamId.value === awayTeamId.value)
const isMyTeam = computed(() => viewingTeamId.value === props.myTeamId)
const isBatting = computed(() => viewingTeamId.value === gameStore.battingTeamId)
const isFielding = computed(() => viewingTeamId.value === gameStore.fieldingTeamId)
// Computed - Lineups
const currentTeamLineup = computed(() => {
return isViewingAwayTeam.value
? gameStore.awayLineup
: gameStore.homeLineup
})
const activeLineup = computed(() => {
return currentTeamLineup.value.filter(p => p.is_active)
})
const benchPlayers = computed(() => {
return isViewingAwayTeam.value
? gameStore.awayBench
: gameStore.homeBench
})
const battingOrderSlots = computed(() => {
return activeLineup.value
.filter(p => p.batting_order !== null && p.position !== 'P')
.sort((a, b) => (a.batting_order ?? 0) - (b.batting_order ?? 0))
})
// Computed - Runners on base
const runnersOnBase = computed(() => {
const runners: Array<Lineup & { base: '1B' | '2B' | '3B' }> = []
const state = gameStore.gameState
if (!state) return runners
// Get runners from game state and find their lineup data
if (state.on_first) {
const lineup = gameStore.findPlayerInLineup(state.on_first.lineup_id)
if (lineup) runners.push({ ...lineup, base: '1B' })
}
if (state.on_second) {
const lineup = gameStore.findPlayerInLineup(state.on_second.lineup_id)
if (lineup) runners.push({ ...lineup, base: '2B' })
}
if (state.on_third) {
const lineup = gameStore.findPlayerInLineup(state.on_third.lineup_id)
if (lineup) runners.push({ ...lineup, base: '3B' })
}
return runners
})
// Computed - Pitcher
const currentPitcherLineup = computed(() => {
// Get pitcher for the viewing team
return activeLineup.value.find(p => p.position === 'P')
})
const isStartingPitcher = computed(() => {
return currentPitcherLineup.value?.is_starter ?? true
})
// Note: For demo/testing, allow pitcher changes for the team being viewed
const canChangePitcher = computed(() => {
return isFielding.value
})
// Computed - Current batter
const currentBatterLineupId = computed(() => {
return gameStore.currentBatter?.lineup_id ?? null
})
// Computed - Position change slot
const positionChangeSlot = computed(() => {
if (expandedMode.value !== 'position_change' || !expandedSlotId.value) return null
return activeLineup.value.find(p => p.lineup_id === expandedSlotId.value) ?? null
})
// Get slot state for display
function getSlotState(slot: Lineup): 'normal' | 'at_bat' | 'on_base' | 'expanded' {
if (expandedSlotId.value === slot.lineup_id) return 'expanded'
if (isBatting.value && slot.lineup_id === currentBatterLineupId.value) return 'at_bat'
if (runnersOnBase.value.some(r => r.lineup_id === slot.lineup_id)) return 'on_base'
return 'normal'
}
// Get base position for a runner
function getBasePosition(runner: Lineup & { base?: '1B' | '2B' | '3B' }): '1B' | '2B' | '3B' | null {
return runner.base ?? null
}
// Can substitute at this slot?
// Note: For demo/testing, allow subs for whichever team is being viewed
// In production, could restrict to only your managed team
function canSubstitute(slot: Lineup): boolean {
if (!slot.is_active) return false
return true
}
// Can change position at this slot?
// Note: For demo/testing, allow position changes for the team being viewed
function canChangePosition(slot: Lineup): boolean {
if (!slot.is_active) return false
// Can change position when that team is fielding
return isFielding.value
}
// Get substitute button label
function getSubstituteLabel(slot: Lineup): string {
if (isBatting.value && slot.lineup_id === currentBatterLineupId.value) {
return 'Pinch Hit'
}
return 'Substitute'
}
// Get substitution type for a slot
function getSubstitutionType(slot: Lineup): 'pinch_hitter' | 'pinch_runner' | 'defensive_replacement' | 'relief_pitcher' {
if (slot.position === 'P') return 'relief_pitcher'
if (isBatting.value && slot.lineup_id === currentBatterLineupId.value) return 'pinch_hitter'
if (runnersOnBase.value.some(r => r.lineup_id === slot.lineup_id)) return 'pinch_runner'
return 'defensive_replacement'
}
// Open substitution panel for a slot
function openSubstitution(slot: Lineup) {
expandedSlotId.value = slot.lineup_id
expandedMode.value = 'substitution'
// Fetch bench players when opening substitution panel
getBench(viewingTeamId.value)
}
// Open pinch runner panel
function openPinchRunner(runner: Lineup) {
expandedSlotId.value = runner.lineup_id
expandedMode.value = 'pinch_runner'
// Fetch bench players when opening substitution panel
getBench(viewingTeamId.value)
}
// Open position change panel
function openPositionChange(slot: Lineup) {
expandedSlotId.value = slot.lineup_id
expandedMode.value = 'position_change'
}
// Open pitching change panel
function openPitchingChange() {
if (currentPitcherLineup.value) {
expandedSlotId.value = currentPitcherLineup.value.lineup_id
expandedMode.value = 'substitution'
// Fetch bench players when opening substitution panel
getBench(viewingTeamId.value)
}
}
// Close expanded panel
function closeExpanded() {
expandedSlotId.value = null
expandedMode.value = null
}
// Handle substitution submit
async function handleSubstitutionSubmit(payload: {
playerOutLineupId: number
playerInCardId?: number
newPosition: string
teamId: number
type: string
}) {
try {
// Map our type to backend substitution type
let backendType: 'pinch_hitter' | 'defensive_replacement' | 'pitching_change'
switch (payload.type) {
case 'pinch_hitter':
case 'pinch_runner':
backendType = 'pinch_hitter' // Backend treats PR as PH for now
break
case 'relief_pitcher':
backendType = 'pitching_change'
break
case 'position_change':
// TODO: Position change endpoint
console.log('Position change not yet implemented:', payload)
closeExpanded()
return
default:
backendType = 'defensive_replacement'
}
if (payload.playerInCardId) {
await submitSubstitution({
game_id: props.gameId,
type: backendType,
player_out_lineup_id: payload.playerOutLineupId,
player_in_card_id: payload.playerInCardId,
team_id: payload.teamId,
new_position: payload.newPosition,
})
}
closeExpanded()
} catch (error) {
console.error('Substitution failed:', error)
// TODO: Show error toast
}
}
// Handle lineup submit from LineupBuilder
function handleLineupSubmit(result: unknown) {
emit('lineupsSubmitted', result)
}
</script>
<style scoped>
.unified-lineup-tab {
@apply w-full;
}
</style>