strat-gameplay-webapp/.claude/implementation/frontend-architecture.md
2025-10-22 11:22:15 -05:00

779 lines
19 KiB
Markdown

# Frontend Architecture
## Overview
Two separate Nuxt 3 applications (one per league) with maximum code reuse through a shared component library. Mobile-first design with real-time WebSocket updates.
## Directory Structure
### Per-League Frontend (`frontend-sba/` and `frontend-pd/`)
```
frontend-{league}/
├── assets/ # Static assets
│ ├── css/
│ │ └── tailwind.css # Tailwind imports
│ └── images/
│ ├── logo.png
│ └── field-bg.svg
├── components/ # League-specific components
│ ├── Branding/
│ │ ├── Header.vue
│ │ ├── Footer.vue
│ │ └── Logo.vue
│ └── League/
│ └── SpecialFeatures.vue # League-specific UI elements
├── composables/ # Vue composables
│ ├── useAuth.ts # Authentication state
│ ├── useWebSocket.ts # WebSocket connection
│ ├── useGameState.ts # Game state management
│ └── useLeagueConfig.ts # League-specific config
├── layouts/
│ ├── default.vue # Standard layout
│ ├── game.vue # Game view layout
│ └── auth.vue # Auth pages layout
├── pages/
│ ├── index.vue # Home/dashboard
│ ├── games/
│ │ ├── [id].vue # Game view
│ │ ├── create.vue # Create new game
│ │ └── history.vue # Completed games
│ ├── auth/
│ │ ├── login.vue
│ │ └── callback.vue # Discord OAuth callback
│ └── spectate/
│ └── [id].vue # Spectator view
├── plugins/
│ ├── socket.client.ts # Socket.io plugin
│ └── auth.ts # Auth plugin
├── store/ # Pinia stores
│ ├── auth.ts # Authentication state
│ ├── game.ts # Current game state
│ ├── games.ts # Games list
│ └── ui.ts # UI state (modals, toasts)
├── types/
│ ├── game.ts # Game-related types
│ ├── player.ts # Player types
│ ├── api.ts # API response types
│ └── websocket.ts # WebSocket event types
├── utils/
│ ├── api.ts # API client
│ ├── formatters.ts # Data formatting utilities
│ └── validators.ts # Input validation
├── middleware/
│ ├── auth.ts # Auth guard
│ └── game-access.ts # Game access validation
├── app.vue # Root component
├── nuxt.config.ts # Nuxt configuration
├── tailwind.config.js # Tailwind configuration
├── tsconfig.json # TypeScript configuration
└── package.json
```
### Shared Component Library (`shared-components/`)
```
shared-components/
├── src/
│ ├── components/
│ │ ├── Game/
│ │ │ ├── GameBoard.vue # Baseball diamond visualization
│ │ │ ├── ScoreBoard.vue # Score display
│ │ │ ├── PlayByPlay.vue # Play history feed
│ │ │ ├── CurrentSituation.vue # Current game context
│ │ │ └── BaseRunners.vue # Runner indicators
│ │ │
│ │ ├── Decisions/
│ │ │ ├── DefensivePositioning.vue
│ │ │ ├── StolenBaseAttempt.vue
│ │ │ ├── OffensiveApproach.vue
│ │ │ └── DecisionTimer.vue
│ │ │
│ │ ├── Actions/
│ │ │ ├── SubstitutionModal.vue
│ │ │ ├── PitchingChange.vue
│ │ │ └── ActionButton.vue
│ │ │
│ │ ├── Display/
│ │ │ ├── PlayerCard.vue # Player card display
│ │ │ ├── DiceRoll.vue # Dice animation
│ │ │ ├── PlayOutcome.vue # Play result display
│ │ │ └── ConnectionStatus.vue # WebSocket status
│ │ │
│ │ └── Common/
│ │ ├── Button.vue
│ │ ├── Modal.vue
│ │ ├── Toast.vue
│ │ └── Loading.vue
│ │
│ ├── composables/
│ │ ├── useGameActions.ts # Shared game actions
│ │ └── useGameDisplay.ts # Shared display logic
│ │
│ └── types/
│ └── index.ts # Shared TypeScript types
├── package.json
└── tsconfig.json
```
## Key Components
### 1. WebSocket Composable (`composables/useWebSocket.ts`)
**Responsibilities**:
- Establish and maintain WebSocket connection
- Handle reconnection logic
- Emit events to server
- Subscribe to server events
- Connection status monitoring
**Implementation**:
```typescript
import { io, Socket } from 'socket.io-client'
import { ref, onUnmounted } from 'vue'
export const useWebSocket = () => {
const socket = ref<Socket | null>(null)
const connected = ref(false)
const reconnecting = ref(false)
const connect = (token: string) => {
const config = useRuntimeConfig()
socket.value = io(config.public.wsUrl, {
auth: { token },
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5
})
socket.value.on('connect', () => {
connected.value = true
reconnecting.value = false
})
socket.value.on('disconnect', () => {
connected.value = false
})
socket.value.on('reconnecting', () => {
reconnecting.value = true
})
}
const disconnect = () => {
socket.value?.disconnect()
socket.value = null
connected.value = false
}
const emit = (event: string, data: any) => {
if (!socket.value?.connected) {
throw new Error('WebSocket not connected')
}
socket.value.emit(event, data)
}
const on = (event: string, handler: (...args: any[]) => void) => {
socket.value?.on(event, handler)
}
const off = (event: string, handler?: (...args: any[]) => void) => {
socket.value?.off(event, handler)
}
onUnmounted(() => {
disconnect()
})
return {
socket,
connected,
reconnecting,
connect,
disconnect,
emit,
on,
off
}
}
```
### 2. Game State Store (`store/game.ts`)
**Responsibilities**:
- Hold current game state
- Update state from WebSocket events
- Provide computed properties for UI
- Handle optimistic updates
**Implementation**:
```typescript
import { defineStore } from 'pinia'
import type { GameState, PlayOutcome } from '~/types/game'
export const useGameStore = defineStore('game', () => {
// State
const gameState = ref<GameState | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const pendingAction = ref(false)
// Computed
const inning = computed(() => gameState.value?.inning ?? 1)
const half = computed(() => gameState.value?.half ?? 'top')
const outs = computed(() => gameState.value?.outs ?? 0)
const score = computed(() => ({
home: gameState.value?.home_score ?? 0,
away: gameState.value?.away_score ?? 0
}))
const runners = computed(() => gameState.value?.runners ?? {})
const currentBatter = computed(() => gameState.value?.current_batter)
const currentPitcher = computed(() => gameState.value?.current_pitcher)
const isMyTurn = computed(() => {
// Logic to determine if it's user's turn
return gameState.value?.decision_required?.user_id === useAuthStore().userId
})
// Actions
const setGameState = (state: GameState) => {
gameState.value = state
loading.value = false
error.value = null
}
const updateState = (updates: Partial<GameState>) => {
if (gameState.value) {
gameState.value = { ...gameState.value, ...updates }
}
}
const handlePlayCompleted = (outcome: PlayOutcome) => {
// Update state based on play outcome
if (gameState.value) {
updateState({
outs: outcome.outs_after,
runners: outcome.runners_after,
home_score: outcome.home_score,
away_score: outcome.away_score
})
}
pendingAction.value = false
}
const setError = (message: string) => {
error.value = message
loading.value = false
pendingAction.value = false
}
const reset = () => {
gameState.value = null
loading.value = false
error.value = null
pendingAction.value = false
}
return {
// State
gameState,
loading,
error,
pendingAction,
// Computed
inning,
half,
outs,
score,
runners,
currentBatter,
currentPitcher,
isMyTurn,
// Actions
setGameState,
updateState,
handlePlayCompleted,
setError,
reset
}
})
```
### 3. Game Board Component (`shared-components/Game/GameBoard.vue`)
**Responsibilities**:
- Visual baseball diamond
- Show runners on base
- Highlight active bases
- Responsive scaling
**Implementation**:
```vue
<template>
<div class="game-board relative w-full aspect-square max-w-md mx-auto">
<!-- Diamond SVG -->
<svg viewBox="0 0 100 100" class="w-full h-full">
<!-- Diamond shape -->
<path
d="M 50 10 L 90 50 L 50 90 L 10 50 Z"
class="fill-green-600 stroke-white stroke-2"
/>
<!-- Bases -->
<rect
v-for="base in bases"
:key="base.name"
:x="base.x"
:y="base.y"
width="8"
height="8"
:class="[
'stroke-white stroke-2',
hasRunner(base.name) ? 'fill-yellow-400' : 'fill-white'
]"
/>
<!-- Home plate -->
<polygon
points="50,88 48,90 52,90"
class="fill-white stroke-white"
/>
</svg>
<!-- Runner indicators -->
<div
v-for="(runner, base) in runners"
:key="base"
:class="getRunnerPositionClass(base)"
class="absolute"
>
<PlayerAvatar
v-if="runner"
:player-id="runner"
size="sm"
/>
</div>
<!-- Batter indicator -->
<div class="absolute bottom-2 left-1/2 -translate-x-1/2">
<span class="text-xs text-white font-bold">
{{ currentBatter?.name }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { Runners } from '~/types/game'
interface Props {
runners: Runners
currentBatter?: { name: string }
}
const props = defineProps<Props>()
const bases = [
{ name: 'first', x: 84, y: 46 },
{ name: 'second', x: 46, y: 6 },
{ name: 'third', x: 6, y: 46 }
]
const hasRunner = (base: string) => {
return props.runners[base] !== null
}
const getRunnerPositionClass = (base: string) => {
const positions = {
first: 'right-8 top-1/2 -translate-y-1/2',
second: 'top-8 left-1/2 -translate-x-1/2',
third: 'left-8 top-1/2 -translate-y-1/2'
}
return positions[base as keyof typeof positions]
}
</script>
```
### 4. Decision Flow Component (`shared-components/Decisions/DefensivePositioning.vue`)
**Responsibilities**:
- Present defensive positioning options
- Validate selection
- Submit decision via WebSocket
- Show loading state
**Implementation**:
```vue
<template>
<div class="decision-card bg-white rounded-lg shadow-lg p-6">
<h3 class="text-lg font-bold mb-4">Set Defensive Positioning</h3>
<div class="space-y-3">
<button
v-for="option in positioningOptions"
:key="option.value"
@click="selectPositioning(option.value)"
:disabled="submitting"
class="w-full py-3 px-4 rounded-lg border-2 transition-colors"
:class="[
selected === option.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-blue-300',
submitting && 'opacity-50 cursor-not-allowed'
]"
>
<div class="text-left">
<div class="font-semibold">{{ option.label }}</div>
<div class="text-sm text-gray-600">{{ option.description }}</div>
</div>
</button>
</div>
<div class="mt-6 flex justify-between items-center">
<DecisionTimer
v-if="timeoutSeconds"
:seconds="timeoutSeconds"
@timeout="handleTimeout"
/>
<button
@click="submitDecision"
:disabled="!selected || submitting"
class="btn-primary"
>
{{ submitting ? 'Submitting...' : 'Confirm' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
timeoutSeconds?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
submit: [positioning: string]
timeout: []
}>()
const selected = ref<string | null>(null)
const submitting = ref(false)
const positioningOptions = [
{
value: 'standard',
label: 'Standard',
description: 'Normal defensive alignment'
},
{
value: 'infield_in',
label: 'Infield In',
description: 'Drawn in to prevent runs'
},
{
value: 'shift_left',
label: 'Shift Left',
description: 'Shifted for pull hitter'
},
{
value: 'shift_right',
label: 'Shift Right',
description: 'Shifted for opposite field'
}
]
const selectPositioning = (value: string) => {
if (!submitting.value) {
selected.value = value
}
}
const submitDecision = async () => {
if (!selected.value || submitting.value) return
submitting.value = true
emit('submit', selected.value)
}
const handleTimeout = () => {
emit('timeout')
}
</script>
```
## State Management Architecture
### Pinia Stores
**Auth Store**:
- User authentication state
- Discord profile data
- Team ownership information
- Token management
**Game Store**:
- Current game state
- Real-time updates from WebSocket
- Pending actions
- Error handling
**Games List Store**:
- Active games list
- Completed games history
- Game filtering
**UI Store**:
- Modal states
- Toast notifications
- Loading indicators
- Theme preferences
## Mobile-First Responsive Design
### Breakpoints
```javascript
// tailwind.config.js
module.exports = {
theme: {
screens: {
'xs': '375px', // Small phones
'sm': '640px', // Large phones
'md': '768px', // Tablets
'lg': '1024px', // Desktop
'xl': '1280px', // Large desktop
}
}
}
```
### Layout Strategy
**Mobile (< 768px)**:
- Single column layout
- Bottom sheet for decisions
- Sticky scoreboard at top
- Collapsible play-by-play
- Full-screen game board
**Tablet (768px - 1024px)**:
- Two column layout (game + sidebar)
- Larger game board
- Side panel for decisions
- Expanded play history
**Desktop (> 1024px)**:
- Three column layout (optional)
- Full game board center
- Decision panel right
- Stats panel left
## WebSocket Event Handling
### Event Listeners Setup
```typescript
// composables/useGameActions.ts
export const useGameActions = (gameId: string) => {
const { socket, emit, on, off } = useWebSocket()
const gameStore = useGameStore()
onMounted(() => {
// Join game room
emit('join_game', { game_id: gameId, role: 'player' })
// Listen for state updates
on('game_state_update', (data: GameState) => {
gameStore.setGameState(data)
})
on('play_completed', (data: PlayOutcome) => {
gameStore.handlePlayCompleted(data)
})
on('dice_rolled', (data: DiceRoll) => {
// Trigger dice animation
showDiceRoll(data.roll, data.animation_duration)
})
on('decision_required', (data: DecisionPrompt) => {
// Show decision UI
gameStore.setDecisionRequired(data)
})
on('invalid_action', (data: ErrorData) => {
gameStore.setError(data.message)
})
on('game_error', (data: ErrorData) => {
gameStore.setError(data.message)
})
})
onUnmounted(() => {
// Clean up listeners
off('game_state_update')
off('play_completed')
off('dice_rolled')
off('decision_required')
off('invalid_action')
off('game_error')
// Leave game room
emit('leave_game', { game_id: gameId })
})
// Action methods
const setDefense = (positioning: string) => {
emit('set_defense', { game_id: gameId, positioning })
}
const setStolenBase = (runners: string[]) => {
emit('set_stolen_base', { game_id: gameId, runners })
}
const setOffensiveApproach = (approach: string) => {
emit('set_offensive_approach', { game_id: gameId, approach })
}
return {
setDefense,
setStolenBase,
setOffensiveApproach
}
}
```
## League-Specific Customization
### Configuration
```typescript
// frontend-sba/nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
public: {
leagueId: 'sba',
leagueName: 'Stratomatic Baseball Association',
apiUrl: process.env.NUXT_PUBLIC_API_URL,
wsUrl: process.env.NUXT_PUBLIC_WS_URL,
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID,
primaryColor: '#1e40af', // Blue
secondaryColor: '#dc2626' // Red
}
}
})
// frontend-pd/nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
public: {
leagueId: 'pd',
leagueName: 'Paper Dynasty',
apiUrl: process.env.NUXT_PUBLIC_API_URL,
wsUrl: process.env.NUXT_PUBLIC_WS_URL,
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID,
primaryColor: '#16a34a', // Green
secondaryColor: '#ea580c' // Orange
}
}
})
```
### Theming
```typescript
// composables/useLeagueConfig.ts
export const useLeagueConfig = () => {
const config = useRuntimeConfig()
return {
leagueId: config.public.leagueId,
leagueName: config.public.leagueName,
colors: {
primary: config.public.primaryColor,
secondary: config.public.secondaryColor
},
features: {
showScoutingData: config.public.leagueId === 'pd',
useSimplePlayerCards: config.public.leagueId === 'sba'
}
}
}
```
## Performance Optimizations
### Code Splitting
- Lazy load game components
- Route-based code splitting
- Dynamic imports for heavy libraries
### Asset Optimization
- Image lazy loading
- SVG sprites for icons
- Optimized font loading
### State Updates
- Debounce non-critical updates
- Optimistic UI updates
- Efficient re-rendering with `v-memo`
## Error Handling
### Network Errors
```typescript
const handleNetworkError = (error: Error) => {
const uiStore = useUiStore()
if (error.message.includes('WebSocket')) {
uiStore.showToast({
type: 'error',
message: 'Connection lost. Reconnecting...'
})
} else {
uiStore.showToast({
type: 'error',
message: 'Network error. Please try again.'
})
}
}
```
### Game Errors
```typescript
const handleGameError = (error: GameError) => {
const uiStore = useUiStore()
uiStore.showModal({
title: 'Game Error',
message: error.message,
actions: [
{
label: 'Retry',
handler: () => retryLastAction()
},
{
label: 'Reload Game',
handler: () => reloadGameState()
}
]
})
}
```
---
**Next Steps**: See [03-gameplay-features.md](./03-gameplay-features.md) for gameplay implementation details.