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

19 KiB

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:

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:

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:

<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:

<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

// 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

// 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

// 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

// 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

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

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 for gameplay implementation details.