added match preview and analysis
This commit is contained in:
254
internal/db/match_preview.go
Normal file
254
internal/db/match_preview.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// Game outcome type constants.
|
||||
const (
|
||||
OutcomeWin = "W"
|
||||
OutcomeLoss = "L"
|
||||
OutcomeOTWin = "OTW"
|
||||
OutcomeOTLoss = "OTL"
|
||||
OutcomeDraw = "D"
|
||||
OutcomeForfeit = "F"
|
||||
)
|
||||
|
||||
// GameOutcome represents the result of a single game from a team's perspective.
|
||||
type GameOutcome struct {
|
||||
Type string // One of Outcome* constants: "W", "L", "OTW", "OTL", "D", "F"
|
||||
Opponent *Team // The opposing team (may be nil if relation not loaded)
|
||||
Score string // e.g. "3-1" or "" for forfeits
|
||||
IsForfeit bool // Whether this game was decided by forfeit
|
||||
Fixture *Fixture // The fixture itself
|
||||
}
|
||||
|
||||
// MatchPreviewData holds all computed data needed for the match preview tab.
|
||||
type MatchPreviewData struct {
|
||||
HomeRecord *TeamRecord
|
||||
AwayRecord *TeamRecord
|
||||
HomePosition int
|
||||
AwayPosition int
|
||||
TotalTeams int
|
||||
HomeRecentGames []*GameOutcome
|
||||
AwayRecentGames []*GameOutcome
|
||||
}
|
||||
|
||||
// ComputeRecentGames calculates the last N game outcomes for a given team.
|
||||
// Fixtures should be all allocated fixtures for the season+league.
|
||||
// Results should be finalized results mapped by fixture ID.
|
||||
// Schedules should be accepted schedules mapped by fixture ID (for ordering by scheduled time).
|
||||
// The returned outcomes are in chronological order (oldest first, newest last).
|
||||
func ComputeRecentGames(
|
||||
teamID int,
|
||||
fixtures []*Fixture,
|
||||
resultMap map[int]*FixtureResult,
|
||||
scheduleMap map[int]*FixtureSchedule,
|
||||
limit int,
|
||||
) []*GameOutcome {
|
||||
// Collect fixtures involving this team that have finalized results
|
||||
type fixtureWithTime struct {
|
||||
fixture *Fixture
|
||||
result *FixtureResult
|
||||
time time.Time
|
||||
}
|
||||
var played []fixtureWithTime
|
||||
|
||||
for _, f := range fixtures {
|
||||
if f.HomeTeamID != teamID && f.AwayTeamID != teamID {
|
||||
continue
|
||||
}
|
||||
res, ok := resultMap[f.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Use schedule time for ordering, fall back to result creation time
|
||||
t := time.Unix(res.CreatedAt, 0)
|
||||
if scheduleMap != nil {
|
||||
if sched, ok := scheduleMap[f.ID]; ok && sched.ScheduledTime != nil {
|
||||
t = *sched.ScheduledTime
|
||||
}
|
||||
}
|
||||
played = append(played, fixtureWithTime{fixture: f, result: res, time: t})
|
||||
}
|
||||
|
||||
// Sort by time descending (most recent first)
|
||||
sort.Slice(played, func(i, j int) bool {
|
||||
return played[i].time.After(played[j].time)
|
||||
})
|
||||
|
||||
// Take only the most recent N
|
||||
if len(played) > limit {
|
||||
played = played[:limit]
|
||||
}
|
||||
|
||||
// Reverse to chronological order (oldest first)
|
||||
for i, j := 0, len(played)-1; i < j; i, j = i+1, j-1 {
|
||||
played[i], played[j] = played[j], played[i]
|
||||
}
|
||||
|
||||
// Build outcome list
|
||||
outcomes := make([]*GameOutcome, len(played))
|
||||
for i, p := range played {
|
||||
outcomes[i] = buildGameOutcome(teamID, p.fixture, p.result)
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
// buildGameOutcome determines the outcome type for a single game from a team's perspective.
|
||||
// Note: fixtures must have their HomeTeam and AwayTeam relations loaded.
|
||||
func buildGameOutcome(teamID int, fixture *Fixture, result *FixtureResult) *GameOutcome {
|
||||
isHome := fixture.HomeTeamID == teamID
|
||||
var opponent *Team
|
||||
if isHome {
|
||||
opponent = fixture.AwayTeam // may be nil if relation not loaded
|
||||
} else {
|
||||
opponent = fixture.HomeTeam // may be nil if relation not loaded
|
||||
}
|
||||
|
||||
outcome := &GameOutcome{
|
||||
Opponent: opponent,
|
||||
Fixture: fixture,
|
||||
}
|
||||
|
||||
// Handle forfeits
|
||||
if result.IsForfeit {
|
||||
outcome.IsForfeit = true
|
||||
outcome.Type = OutcomeForfeit
|
||||
if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeMutual {
|
||||
outcome.Type = OutcomeOTLoss // mutual forfeit counts as OT loss for both
|
||||
} else if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeOutright {
|
||||
thisSide := "away"
|
||||
if isHome {
|
||||
thisSide = "home"
|
||||
}
|
||||
if result.ForfeitTeam != nil && *result.ForfeitTeam == thisSide {
|
||||
outcome.Type = OutcomeLoss // this team forfeited
|
||||
} else {
|
||||
outcome.Type = OutcomeWin // opponent forfeited
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
// Normal match - build score string from this team's perspective
|
||||
if isHome {
|
||||
outcome.Score = fmt.Sprintf("%d-%d", result.HomeScore, result.AwayScore)
|
||||
} else {
|
||||
outcome.Score = fmt.Sprintf("%d-%d", result.AwayScore, result.HomeScore)
|
||||
}
|
||||
|
||||
won := (isHome && result.Winner == "home") || (!isHome && result.Winner == "away")
|
||||
lost := (isHome && result.Winner == "away") || (!isHome && result.Winner == "home")
|
||||
isOT := strings.EqualFold(result.EndReason, "Overtime")
|
||||
|
||||
switch {
|
||||
case won && isOT:
|
||||
outcome.Type = OutcomeOTWin
|
||||
case won:
|
||||
outcome.Type = OutcomeWin
|
||||
case lost && isOT:
|
||||
outcome.Type = OutcomeOTLoss
|
||||
case lost:
|
||||
outcome.Type = OutcomeLoss
|
||||
default:
|
||||
outcome.Type = OutcomeDraw
|
||||
}
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
// GetTeamsForSeasonLeague returns all teams participating in a given season+league.
|
||||
func GetTeamsForSeasonLeague(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Team, error) {
|
||||
var teams []*Team
|
||||
err := tx.NewSelect().
|
||||
Model(&teams).
|
||||
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
|
||||
Where("tp.season_id = ? AND tp.league_id = ?", seasonID, leagueID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
// ComputeMatchPreview fetches all data needed for the match preview tab:
|
||||
// team standings, positions, and recent game outcomes for both teams.
|
||||
func ComputeMatchPreview(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixture *Fixture,
|
||||
) (*MatchPreviewData, error) {
|
||||
if fixture == nil {
|
||||
return nil, errors.New("fixture cannot be nil")
|
||||
}
|
||||
|
||||
// Get all teams in this season+league
|
||||
allTeams, err := GetTeamsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetTeamsForSeasonLeague")
|
||||
}
|
||||
|
||||
// Get all allocated fixtures for the season+league
|
||||
allFixtures, err := GetAllocatedFixtures(ctx, tx, fixture.SeasonID, fixture.LeagueID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetAllocatedFixtures")
|
||||
}
|
||||
|
||||
// Get finalized results
|
||||
allFixtureIDs := make([]int, len(allFixtures))
|
||||
for i, f := range allFixtures {
|
||||
allFixtureIDs[i] = f.ID
|
||||
}
|
||||
allResultMap, err := GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures")
|
||||
}
|
||||
|
||||
// Get accepted schedules for ordering recent games
|
||||
allScheduleMap, err := GetAcceptedSchedulesForFixtures(ctx, tx, allFixtureIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetAcceptedSchedulesForFixtures")
|
||||
}
|
||||
|
||||
// Compute leaderboard
|
||||
leaderboard := ComputeLeaderboard(allTeams, allFixtures, allResultMap)
|
||||
|
||||
// Extract positions and records for both teams
|
||||
preview := &MatchPreviewData{
|
||||
TotalTeams: len(leaderboard),
|
||||
}
|
||||
for _, entry := range leaderboard {
|
||||
if entry.Team.ID == fixture.HomeTeamID {
|
||||
preview.HomePosition = entry.Position
|
||||
preview.HomeRecord = entry.Record
|
||||
}
|
||||
if entry.Team.ID == fixture.AwayTeamID {
|
||||
preview.AwayPosition = entry.Position
|
||||
preview.AwayRecord = entry.Record
|
||||
}
|
||||
}
|
||||
if preview.HomeRecord == nil {
|
||||
preview.HomeRecord = &TeamRecord{}
|
||||
}
|
||||
if preview.AwayRecord == nil {
|
||||
preview.AwayRecord = &TeamRecord{}
|
||||
}
|
||||
|
||||
// Compute recent games (last 5) for each team
|
||||
preview.HomeRecentGames = ComputeRecentGames(
|
||||
fixture.HomeTeamID, allFixtures, allResultMap, allScheduleMap, 5,
|
||||
)
|
||||
preview.AwayRecentGames = ComputeRecentGames(
|
||||
fixture.AwayTeamID, allFixtures, allResultMap, allScheduleMap, 5,
|
||||
)
|
||||
|
||||
return preview, nil
|
||||
}
|
||||
Reference in New Issue
Block a user