255 lines
7.3 KiB
Go
255 lines
7.3 KiB
Go
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
|
|
}
|