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 }