strat-gameplay-webapp/frontend-sba/pages/index.vue
Cal Corum fbbb1cc5da CLAUDE: Add SBA schedule integration with weekly matchup display
Implements schedule viewing from SBA production API with week navigation
and game creation from scheduled matchups. Groups games by team matchup
horizontally with games stacked vertically for space efficiency.

Backend:
- Add schedule routes (/api/schedule/current, /api/schedule/games)
- Add SBA API client methods for schedule data
- Fix multi-worker state isolation (single worker for in-memory state)
- Add Redis migration TODO for future scalability
- Support custom team IDs in quick-create endpoint

Frontend:
- Add Schedule tab as default on home page
- Week navigation with prev/next and "Current Week" jump
- Horizontal group layout (2-6 columns responsive)
- Completed games show score + "Final" badge (no Play button)
- Incomplete games show "Play" button to create webapp game

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:39:31 -06:00

525 lines
18 KiB
Vue
Executable File

<template>
<div class="bg-white rounded-xl shadow-md border border-gray-200 p-6">
<!-- Page Header -->
<div class="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">My Games</h1>
<p class="text-gray-600">
View and manage your active and completed games
</p>
</div>
<div class="flex flex-wrap gap-3">
<button
@click="handleQuickCreate"
:disabled="isCreatingQuickGame"
class="px-5 py-2.5 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white font-medium rounded-lg shadow hover:shadow-md transition disabled:cursor-not-allowed"
>
{{ isCreatingQuickGame ? 'Creating...' : 'Quick Start Demo' }}
</button>
<NuxtLink
to="/games/create"
class="px-5 py-2.5 bg-primary hover:bg-blue-700 text-white font-medium rounded-lg shadow hover:shadow-md transition"
>
Create New Game
</NuxtLink>
</div>
</div>
<!-- Tabs -->
<div class="mb-6 border-b border-gray-200">
<nav class="flex space-x-8">
<button
:class="[
'py-4 px-1 border-b-2 font-medium text-sm transition',
activeTab === 'schedule'
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
@click="handleTabChange('schedule')"
>
Schedule
</button>
<button
:class="[
'py-4 px-1 border-b-2 font-medium text-sm transition',
activeTab === 'active'
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
@click="handleTabChange('active')"
>
Active Games
</button>
<button
:class="[
'py-4 px-1 border-b-2 font-medium text-sm transition',
activeTab === 'completed'
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
@click="handleTabChange('completed')"
>
Completed
</button>
</nav>
</div>
<!-- Loading State (for games list) -->
<div v-if="loading && activeTab !== 'schedule'" class="bg-white rounded-lg shadow-md p-12 text-center">
<div class="w-16 h-16 mx-auto mb-4 border-4 border-primary border-t-transparent rounded-full animate-spin"/>
<p class="text-gray-900 font-semibold">Loading games...</p>
</div>
<!-- Error State (for games list) -->
<div v-else-if="error && activeTab !== 'schedule'" class="bg-red-50 border border-red-200 rounded-lg p-6">
<p class="text-red-800 font-semibold">Failed to load games</p>
<p class="text-red-600 text-sm mt-2">{{ error }}</p>
<button
class="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
@click="() => refresh()"
>
Retry
</button>
</div>
<!-- Schedule Tab -->
<div v-else-if="activeTab === 'schedule'">
<!-- Week Navigation -->
<ScheduleWeekNavigation
:selected-season="schedule.selectedSeason.value"
:selected-week="schedule.selectedWeek.value"
:is-current-week="schedule.isCurrentWeek.value"
@prev="schedule.prevWeek"
@next="schedule.nextWeek"
@go-to-current="schedule.goToCurrentWeek"
class="mb-6"
/>
<!-- Schedule Loading -->
<div v-if="schedule.loading.value" class="bg-white rounded-lg shadow-md p-12 text-center">
<div class="w-16 h-16 mx-auto mb-4 border-4 border-primary border-t-transparent rounded-full animate-spin"/>
<p class="text-gray-900 font-semibold">Loading schedule...</p>
</div>
<!-- Schedule Error -->
<div v-else-if="schedule.error.value" class="bg-red-50 border border-red-200 rounded-lg p-6">
<p class="text-red-800 font-semibold">Failed to load schedule</p>
<p class="text-red-600 text-sm mt-2">{{ schedule.error.value }}</p>
<button
class="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
@click="schedule.fetchGames"
>
Retry
</button>
</div>
<!-- Schedule Games - Grouped by Matchup (horizontal groups, vertical games) -->
<div v-if="groupedScheduleGames.length > 0" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div
v-for="group in groupedScheduleGames"
:key="group.key"
class="bg-white rounded-lg shadow-md p-3 border border-gray-200"
>
<!-- Matchup Header (compact) -->
<div class="text-center mb-3 pb-2 border-b border-gray-200">
<div class="font-bold text-sm text-gray-900">
{{ group.awayTeam.abbrev }} @ {{ group.homeTeam.abbrev }}
</div>
</div>
<!-- Games stacked vertically -->
<div class="space-y-2">
<div
v-for="(game, idx) in group.games"
:key="game.id"
class="flex items-center gap-2 p-2 bg-gray-50 rounded border border-gray-100"
>
<span class="text-xs text-gray-500 w-6">G{{ idx + 1 }}</span>
<!-- Completed game: show score and "Final" badge -->
<template v-if="game.away_score !== null && game.home_score !== null">
<span class="text-xs font-semibold text-gray-700 flex-1">
{{ game.away_score }}-{{ game.home_score }}
</span>
<span class="px-2 py-1 text-xs bg-gray-200 text-gray-600 font-medium rounded">
Final
</span>
</template>
<!-- Incomplete game: show Play button -->
<template v-else>
<span class="flex-1"></span>
<button
@click="handlePlayScheduledGame(game.home_team.id, game.away_team.id)"
:disabled="isCreatingQuickGame"
class="px-2 py-1 text-xs bg-primary hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium rounded transition disabled:cursor-not-allowed"
>
Play
</button>
</template>
</div>
</div>
</div>
</div>
<!-- Empty Schedule State -->
<div v-if="!schedule.loading.value && !schedule.error.value && (!schedule.games.value || schedule.games.value.length === 0)" class="bg-white rounded-lg shadow-md p-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mx-auto text-gray-400 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<h3 class="text-xl font-bold text-gray-900 mb-2">
No Games Scheduled
</h3>
<p class="text-gray-600">
No games are scheduled for this week.
</p>
</div>
</div>
<!-- Active Games Tab -->
<div v-else-if="activeTab === 'active'">
<!-- Games List -->
<div v-if="activeGames.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<NuxtLink
v-for="game in activeGames"
:key="game.game_id"
:to="`/games/${game.game_id}`"
class="bg-white rounded-lg shadow-md hover:shadow-xl transition p-6"
>
<div class="flex justify-between items-start mb-4">
<span
:class="[
'px-3 py-1 rounded-full text-sm font-semibold',
game.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
]"
>
{{ game.status === 'active' ? 'In Progress' : 'Pending Lineups' }}
</span>
<!-- Inning indicator for active games -->
<span v-if="game.status === 'active' && game.inning" class="text-sm text-gray-500">
{{ game.half === 'top' ? 'Top' : 'Bot' }} {{ game.inning }}
</span>
</div>
<!-- Score display -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400 w-8">Away</span>
<span class="font-semibold">{{ game.away_team_name || game.away_team_abbrev || `Team ${game.away_team_id}` }}</span>
</div>
<span class="text-xl font-bold tabular-nums">{{ game.away_score }}</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400 w-8">Home</span>
<span class="font-semibold">{{ game.home_team_name || game.home_team_abbrev || `Team ${game.home_team_id}` }}</span>
</div>
<span class="text-xl font-bold tabular-nums">{{ game.home_score }}</span>
</div>
</div>
</NuxtLink>
</div>
<!-- Empty State -->
<div v-else class="bg-white rounded-lg shadow-md p-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mx-auto text-gray-400 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<h3 class="text-xl font-bold text-gray-900 mb-2">
No Active Games
</h3>
<p class="text-gray-600 mb-6">
You don't have any active games right now. Create a new game to get started!
</p>
<NuxtLink
to="/games/create"
class="inline-block px-6 py-3 bg-primary hover:bg-blue-700 text-white font-semibold rounded-lg transition"
>
Create Your First Game
</NuxtLink>
</div>
</div>
<!-- Completed Games Tab -->
<div v-else-if="activeTab === 'completed'">
<!-- Completed Games List -->
<div v-if="completedGames.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<NuxtLink
v-for="game in completedGames"
:key="game.game_id"
:to="`/games/${game.game_id}`"
class="bg-white rounded-lg shadow-md hover:shadow-xl transition p-6"
>
<div class="flex justify-between items-start mb-4">
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-gray-100 text-gray-800">
Final
</span>
</div>
<!-- Score display -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400 w-8">Away</span>
<span class="font-semibold">{{ game.away_team_name || game.away_team_abbrev || `Team ${game.away_team_id}` }}</span>
</div>
<span class="text-xl font-bold tabular-nums">{{ game.away_score }}</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400 w-8">Home</span>
<span class="font-semibold">{{ game.home_team_name || game.home_team_abbrev || `Team ${game.home_team_id}` }}</span>
</div>
<span class="text-xl font-bold tabular-nums">{{ game.home_score }}</span>
</div>
</div>
</NuxtLink>
</div>
<!-- Empty State -->
<div v-else class="bg-white rounded-lg shadow-md p-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mx-auto text-gray-400 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 class="text-xl font-bold text-gray-900 mb-2">
No Completed Games
</h3>
<p class="text-gray-600">
You haven't completed any games yet.
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '~/store/auth'
definePageMeta({
middleware: ['auth'], // Require authentication
})
const activeTab = ref<'schedule' | 'active' | 'completed'>('schedule')
const config = useRuntimeConfig()
const authStore = useAuthStore()
const router = useRouter()
// Games data - loading/error managed separately, games comes from useAsyncData
const loading = ref(true)
const error = ref<string | null>(null)
const isCreatingQuickGame = ref(false)
// Schedule composable
const schedule = useSchedule()
// Group schedule games by matchup (same away_team + home_team)
// Each group contains games sorted by game_id ascending
interface ScheduleGroup {
key: string
awayTeam: { id: number; abbrev: string; sname: string }
homeTeam: { id: number; abbrev: string; sname: string }
games: typeof schedule.games.value
}
const groupedScheduleGames = computed<ScheduleGroup[]>(() => {
const gamesArray = schedule.games.value
if (!gamesArray || gamesArray.length === 0) return []
// Group by matchup key (away_team_id-home_team_id)
const groups = new Map<string, ScheduleGroup>()
for (const game of gamesArray) {
const key = `${game.away_team.id}-${game.home_team.id}`
if (!groups.has(key)) {
groups.set(key, {
key,
awayTeam: game.away_team,
homeTeam: game.home_team,
games: [],
})
}
groups.get(key)!.games.push(game)
}
// Sort games within each group by game_id ascending
for (const group of groups.values()) {
group.games.sort((a, b) => a.id - b.id)
}
// Convert to array and sort groups by first game's id
return Array.from(groups.values()).sort((a, b) => a.games[0].id - b.games[0].id)
})
// Quick-create a demo game with pre-configured lineups
async function handleQuickCreate() {
try {
isCreatingQuickGame.value = true
error.value = null
console.log('[Games Page] Quick-creating demo game...')
const response = await $fetch<{ game_id: string; message: string; status: string }>(
`${config.public.apiUrl}/api/games/quick-create`,
{
method: 'POST',
credentials: 'include', // Send HttpOnly cookies
}
)
console.log('[Games Page] Quick-create response:', response)
// Redirect to game page
router.push(`/games/${response.game_id}`)
} catch (err: any) {
console.error('[Games Page] Quick-create failed:', err)
error.value = err.data?.detail || err.message || 'Failed to create demo game'
} finally {
isCreatingQuickGame.value = false
}
}
// Create a game from a scheduled matchup
async function handlePlayScheduledGame(homeTeamId: number, awayTeamId: number) {
try {
isCreatingQuickGame.value = true
error.value = null
console.log(`[Games Page] Creating game: ${awayTeamId} @ ${homeTeamId}`)
const response = await $fetch<{ game_id: string; message: string; status: string }>(
`${config.public.apiUrl}/api/games/quick-create`,
{
method: 'POST',
credentials: 'include',
body: {
home_team_id: homeTeamId,
away_team_id: awayTeamId,
},
}
)
console.log('[Games Page] Created game from schedule:', response)
// Redirect to game page
router.push(`/games/${response.game_id}`)
} catch (err: any) {
console.error('[Games Page] Failed to create game from schedule:', err)
error.value = err.data?.detail || err.message || 'Failed to create game'
} finally {
isCreatingQuickGame.value = false
}
}
// Handle tab change
function handleTabChange(tab: 'schedule' | 'active' | 'completed') {
activeTab.value = tab
// Initialize schedule data when switching to schedule tab
if (tab === 'schedule' && !schedule.initialized.value) {
schedule.initialize()
}
}
// Fetch games - uses internal URL for SSR, public URL for client
const apiUrl = useApiUrl()
const { data: games, pending, error: fetchError, refresh } = await useAsyncData(
'games-list',
async () => {
const headers: Record<string, string> = {}
// Forward cookies for SSR requests
if (import.meta.server) {
const event = useRequestEvent()
const cookieHeader = event?.node.req.headers.cookie
if (cookieHeader) {
headers['Cookie'] = cookieHeader
}
}
const response = await $fetch<any[]>(`${apiUrl}/api/games/`, {
credentials: 'include',
headers,
})
return response
},
{
server: true, // SSR enabled - uses internal Docker URL
lazy: false, // Block rendering until done
default: () => [] as any[], // Prevent null during hydration
}
)
// Filter games by status
const activeGames = computed(() => {
return games.value?.filter(g => g.status === 'active' || g.status === 'pending') || []
})
const completedGames = computed(() => {
return games.value?.filter(g => g.status === 'completed' || g.status === 'final') || []
})
// Initialize schedule on mount (since it's the default tab)
onMounted(async () => {
// Initialize schedule data since Schedule is the default tab
if (activeTab.value === 'schedule') {
schedule.initialize()
}
// Only re-fetch games if we have no games but are authenticated
// This handles the case where client-side navigation doesn't trigger SSR
if ((!games.value || games.value.length === 0) && authStore.isAuthenticated) {
console.log('[Games Page] No games on mount, re-fetching...')
await refresh()
}
})
// Sync loading state with pending
watch(pending, (isPending) => {
loading.value = isPending
}, { immediate: true })
// Sync error state
watch(fetchError, (err) => {
if (err) {
error.value = err.message || 'Failed to load games'
}
}, { immediate: true })
</script>
<style scoped>
/* Additional component styles if needed */
</style>