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>
525 lines
18 KiB
Vue
Executable File
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>
|