package db import ( "context" "sort" "strings" "time" "github.com/pkg/errors" "github.com/uptrace/bun" ) type FixtureResult struct { bun.BaseModel `bun:"table:fixture_results,alias:fr"` ID int `bun:"id,pk,autoincrement"` FixtureID int `bun:",notnull,unique"` Winner string `bun:",notnull"` HomeScore int `bun:",notnull"` AwayScore int `bun:",notnull"` MatchType string Arena string EndReason string PeriodsEnabled bool CustomMercyRule int MatchLength int CreatedAt int64 `bun:",notnull"` UpdatedAt *int64 UploadedByUserID int `bun:",notnull"` Finalized bool `bun:",default:false"` TamperingDetected bool `bun:",default:false"` TamperingReason *string // Forfeit-related fields IsForfeit bool `bun:"is_forfeit,default:false"` ForfeitType *string `bun:"forfeit_type"` // "mutual" or "outright" ForfeitTeam *string `bun:"forfeit_team"` // "home" or "away" (nil for mutual) ForfeitReason *string `bun:"forfeit_reason"` // User-provided reason Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"` UploadedBy *User `bun:"rel:belongs-to,join:uploaded_by_user_id=id"` PlayerStats []*FixtureResultPlayerStats `bun:"rel:has-many,join:id=fixture_result_id"` } type FixtureResultPlayerStats struct { bun.BaseModel `bun:"table:fixture_result_player_stats,alias:frps"` ID int `bun:"id,pk,autoincrement"` FixtureResultID int `bun:",notnull"` PeriodNum int `bun:",notnull"` PlayerID *int // NULL for unmapped/free agents PlayerGameUserID string `bun:",notnull"` PlayerUsername string `bun:",notnull"` TeamID *int // NULL for unmapped Team string `bun:",notnull"` // 'home' or 'away' // All stats as INT (nullable) Goals *int Assists *int PrimaryAssists *int SecondaryAssists *int Saves *int Blocks *int Shots *int Turnovers *int Takeaways *int Passes *int PossessionTimeSec *int FaceoffsWon *int FaceoffsLost *int PostHits *int OvertimeGoals *int GameWinningGoals *int Score *int ContributedGoals *int ConcededGoals *int GamesPlayed *int Wins *int Losses *int OvertimeWins *int OvertimeLosses *int Ties *int Shutouts *int ShutoutsAgainst *int HasMercyRuled *int WasMercyRuled *int PeriodsPlayed *int IsFreeAgent bool `bun:"is_free_agent,default:false"` FixtureResult *FixtureResult `bun:"rel:belongs-to,join:fixture_result_id=id"` Player *Player `bun:"rel:belongs-to,join:player_id=id"` TeamRel *Team `bun:"rel:belongs-to,join:team_id=id"` } // PlayerWithPlayStatus is a helper struct for overview display type PlayerWithPlayStatus struct { Player *Player Played bool IsManager bool IsFreeAgent bool Stats *FixtureResultPlayerStats // Period 3 (final cumulative) stats, nil if no result } // Forfeit type constants const ( ForfeitTypeMutual = "mutual" ForfeitTypeOutright = "outright" ) // CreateForfeitResult creates a finalized forfeit result for a fixture. // For outright forfeits, forfeitTeam specifies which team ("home" or "away") forfeited. // For mutual forfeits, forfeitTeam should be empty. func CreateForfeitResult( ctx context.Context, tx bun.Tx, fixture *Fixture, forfeitType string, forfeitTeam string, reason string, userID int, audit *AuditMeta, ) (*FixtureResult, error) { if fixture == nil { return nil, errors.New("fixture cannot be nil") } // Validate forfeit type if forfeitType != ForfeitTypeMutual && forfeitType != ForfeitTypeOutright { return nil, BadRequest("invalid forfeit type: must be 'mutual' or 'outright'") } // Validate forfeit team for outright forfeits if forfeitType == ForfeitTypeOutright { if forfeitTeam != "home" && forfeitTeam != "away" { return nil, BadRequest("outright forfeit requires a team: must be 'home' or 'away'") } } // Determine winner and scores based on forfeit type var winner string var homeScore, awayScore int var endReason string var forfeitTeamPtr *string switch forfeitType { case ForfeitTypeMutual: // Mutual forfeit: both teams get an OT loss, no winner // Use "draw" as winner to signal mutual loss winner = "draw" homeScore = 0 awayScore = 0 endReason = "Forfeit" case ForfeitTypeOutright: // Outright forfeit: forfeiting team loses, opponent wins forfeitTeamPtr = &forfeitTeam if forfeitTeam == "home" { winner = "away" } else { winner = "home" } homeScore = 0 awayScore = 0 endReason = "Forfeit" } var reasonPtr *string if reason != "" { reasonPtr = &reason } result := &FixtureResult{ FixtureID: fixture.ID, Winner: winner, HomeScore: homeScore, AwayScore: awayScore, EndReason: endReason, UploadedByUserID: userID, Finalized: true, // Forfeits are immediately finalized IsForfeit: true, ForfeitType: &forfeitType, ForfeitTeam: forfeitTeamPtr, ForfeitReason: reasonPtr, CreatedAt: time.Now().Unix(), } err := Insert(tx, result).WithAudit(audit, &AuditInfo{ Action: "fixture_results.forfeit", ResourceType: "fixture_result", ResourceID: nil, Details: map[string]any{ "fixture_id": fixture.ID, "forfeit_type": forfeitType, "forfeit_team": forfeitTeam, "reason": reason, }, }).Exec(ctx) if err != nil { return nil, errors.Wrap(err, "Insert forfeit result") } return result, nil } // InsertFixtureResult stores a new match result with all player stats in a single transaction. func InsertFixtureResult( ctx context.Context, tx bun.Tx, result *FixtureResult, playerStats []*FixtureResultPlayerStats, audit *AuditMeta, ) (*FixtureResult, error) { if result == nil { return nil, errors.New("result cannot be nil") } result.CreatedAt = time.Now().Unix() err := Insert(tx, result).WithAudit(audit, &AuditInfo{ Action: "fixture_results.create", ResourceType: "fixture_result", ResourceID: nil, Details: map[string]any{ "fixture_id": result.FixtureID, "winner": result.Winner, "home_score": result.HomeScore, "away_score": result.AwayScore, "tampering_detected": result.TamperingDetected, }, }).Exec(ctx) if err != nil { return nil, errors.Wrap(err, "Insert result") } // Set the fixture_result_id on all player stats for _, ps := range playerStats { ps.FixtureResultID = result.ID } // Insert player stats in bulk if len(playerStats) > 0 { err = InsertMultiple(tx, playerStats).Exec(ctx) if err != nil { return nil, errors.Wrap(err, "InsertMultiple player stats") } } return result, nil } // GetFixtureResult retrieves a result with all player stats for a fixture. // Returns nil, nil if no result exists. func GetFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) { result := new(FixtureResult) err := tx.NewSelect(). Model(result). Where("fr.fixture_id = ?", fixtureID). Relation("Fixture"). Relation("UploadedBy"). Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery { return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC") }). Relation("PlayerStats.Player"). Scan(ctx) if err != nil { if err.Error() == "sql: no rows in result set" { return nil, nil } return nil, errors.Wrap(err, "tx.NewSelect") } return result, nil } // GetPendingFixtureResult retrieves a non-finalized result for review/edit. // Returns nil, nil if no pending result exists. func GetPendingFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) { result := new(FixtureResult) err := tx.NewSelect(). Model(result). Where("fr.fixture_id = ?", fixtureID). Where("fr.finalized = false"). Relation("Fixture"). Relation("UploadedBy"). Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery { return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC") }). Relation("PlayerStats.Player"). Scan(ctx) if err != nil { if err.Error() == "sql: no rows in result set" { return nil, nil } return nil, errors.Wrap(err, "tx.NewSelect") } return result, nil } // FinalizeFixtureResult marks a pending result as finalized. func FinalizeFixtureResult( ctx context.Context, tx bun.Tx, fixtureID int, audit *AuditMeta, ) error { result, err := GetPendingFixtureResult(ctx, tx, fixtureID) if err != nil { return errors.Wrap(err, "GetPendingFixtureResult") } if result == nil { return BadRequest("no pending result to finalize") } now := time.Now().Unix() result.Finalized = true result.UpdatedAt = &now err = UpdateByID(tx, result.ID, result). Column("finalized", "updated_at"). WithAudit(audit, &AuditInfo{ Action: "fixture_results.finalize", ResourceType: "fixture_result", ResourceID: result.ID, Details: map[string]any{ "fixture_id": fixtureID, }, }).Exec(ctx) if err != nil { return errors.Wrap(err, "UpdateByID") } return nil } // DeleteFixtureResult removes a pending result and all associated player stats (CASCADE). func DeleteFixtureResult( ctx context.Context, tx bun.Tx, fixtureID int, audit *AuditMeta, ) error { result, err := GetPendingFixtureResult(ctx, tx, fixtureID) if err != nil { return errors.Wrap(err, "GetPendingFixtureResult") } if result == nil { return BadRequest("no pending result to discard") } err = DeleteByID[FixtureResult](tx, result.ID). WithAudit(audit, &AuditInfo{ Action: "fixture_results.discard", ResourceType: "fixture_result", ResourceID: result.ID, Details: map[string]any{ "fixture_id": fixtureID, }, }).Delete(ctx) if err != nil { return errors.Wrap(err, "DeleteByID") } return nil } // GetFinalizedResultsForFixtures returns finalized results for a list of fixture IDs. // Returns a map of fixtureID -> *FixtureResult (without player stats for efficiency). func GetFinalizedResultsForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs []int) (map[int]*FixtureResult, error) { if len(fixtureIDs) == 0 { return map[int]*FixtureResult{}, nil } results, err := GetList[FixtureResult](tx). Where("fixture_id IN (?)", bun.In(fixtureIDs)). Where("finalized = true"). GetAll(ctx) if err != nil { return nil, errors.Wrap(err, "GetList") } resultMap := make(map[int]*FixtureResult, len(results)) for _, r := range results { resultMap[r.FixtureID] = r } return resultMap, nil } // AggregatedPlayerStats holds summed stats for a player across multiple fixtures. type AggregatedPlayerStats struct { PlayerID int `bun:"player_id"` PlayerName string `bun:"player_name"` GamesPlayed int `bun:"games_played"` PeriodsPlayed int `bun:"total_periods_played"` Score int `bun:"total_score"` Goals int `bun:"total_goals"` Assists int `bun:"total_assists"` Saves int `bun:"total_saves"` Shots int `bun:"total_shots"` Blocks int `bun:"total_blocks"` Passes int `bun:"total_passes"` } // GetAggregatedPlayerStatsForTeam returns aggregated period-3 stats for all mapped // players on a given team across all finalized fixture results. func GetAggregatedPlayerStatsForTeam( ctx context.Context, tx bun.Tx, teamID int, fixtureIDs []int, ) ([]*AggregatedPlayerStats, error) { if len(fixtureIDs) == 0 { return nil, nil } var stats []*AggregatedPlayerStats err := tx.NewRaw(` SELECT frps.player_id AS player_id, COALESCE(p.name, frps.player_username) AS player_name, COUNT(DISTINCT frps.fixture_result_id) AS games_played, COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, COALESCE(SUM(frps.score), 0) AS total_score, COALESCE(SUM(frps.goals), 0) AS total_goals, COALESCE(SUM(frps.assists), 0) AS total_assists, COALESCE(SUM(frps.saves), 0) AS total_saves, COALESCE(SUM(frps.shots), 0) AS total_shots, COALESCE(SUM(frps.blocks), 0) AS total_blocks, COALESCE(SUM(frps.passes), 0) AS total_passes FROM fixture_result_player_stats frps JOIN fixture_results fr ON fr.id = frps.fixture_result_id LEFT JOIN players p ON p.id = frps.player_id WHERE fr.finalized = true AND fr.fixture_id IN (?) AND frps.team_id = ? AND frps.period_num = 3 AND frps.player_id IS NOT NULL GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) ORDER BY total_score DESC `, bun.In(fixtureIDs), teamID).Scan(ctx, &stats) if err != nil { return nil, errors.Wrap(err, "tx.NewRaw") } return stats, nil } // LeaguePlayerStats holds all aggregated stats for a player in a season-league. type LeaguePlayerStats struct { PlayerID int `bun:"player_id"` PlayerName string `bun:"player_name"` TeamID int `bun:"team_id"` TeamName string `bun:"team_name"` TeamColor string `bun:"team_color"` GamesPlayed int `bun:"games_played"` PeriodsPlayed int `bun:"total_periods_played"` Goals int `bun:"total_goals"` Assists int `bun:"total_assists"` PrimaryAssists int `bun:"total_primary_assists"` SecondaryAssists int `bun:"total_secondary_assists"` Saves int `bun:"total_saves"` Shots int `bun:"total_shots"` Blocks int `bun:"total_blocks"` Passes int `bun:"total_passes"` Score int `bun:"total_score"` } // GetAllLeaguePlayerStats returns aggregated stats for all players in a season-league. // Stats are combined across all teams a player may have played on, // and the player's current roster team is shown. func GetAllLeaguePlayerStats( ctx context.Context, tx bun.Tx, seasonID, leagueID int, ) ([]*LeaguePlayerStats, error) { if seasonID == 0 { return nil, errors.New("seasonID not provided") } if leagueID == 0 { return nil, errors.New("leagueID not provided") } var stats []*LeaguePlayerStats err := tx.NewRaw(` SELECT agg.player_id, agg.player_name, COALESCE(tr.team_id, 0) AS team_id, COALESCE(t.name, '') AS team_name, COALESCE(t.color, '') AS team_color, agg.games_played, agg.total_periods_played, agg.total_goals, agg.total_assists, agg.total_primary_assists, agg.total_secondary_assists, agg.total_saves, agg.total_shots, agg.total_blocks, agg.total_passes, agg.total_score FROM ( SELECT frps.player_id AS player_id, COALESCE(p.name, frps.player_username) AS player_name, COUNT(DISTINCT frps.fixture_result_id) AS games_played, COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, COALESCE(SUM(frps.goals), 0) AS total_goals, COALESCE(SUM(frps.assists), 0) AS total_assists, COALESCE(SUM(frps.primary_assists), 0) AS total_primary_assists, COALESCE(SUM(frps.secondary_assists), 0) AS total_secondary_assists, COALESCE(SUM(frps.saves), 0) AS total_saves, COALESCE(SUM(frps.shots), 0) AS total_shots, COALESCE(SUM(frps.blocks), 0) AS total_blocks, COALESCE(SUM(frps.passes), 0) AS total_passes, COALESCE(SUM(frps.score), 0) AS total_score FROM fixture_result_player_stats frps JOIN fixture_results fr ON fr.id = frps.fixture_result_id JOIN fixtures f ON f.id = fr.fixture_id LEFT JOIN players p ON p.id = frps.player_id WHERE fr.finalized = true AND f.season_id = ? AND f.league_id = ? AND f.round > 0 AND frps.period_num = 3 AND frps.player_id IS NOT NULL GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) ) agg LEFT JOIN team_rosters tr ON tr.player_id = agg.player_id AND tr.season_id = ? AND tr.league_id = ? LEFT JOIN teams t ON t.id = tr.team_id ORDER BY agg.total_score DESC `, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats) if err != nil { return nil, errors.Wrap(err, "tx.NewRaw") } return stats, nil } // LeagueTopGoalScorer holds aggregated goal scoring stats for a player in a season-league. type LeagueTopGoalScorer struct { PlayerID int `bun:"player_id"` PlayerName string `bun:"player_name"` TeamID int `bun:"team_id"` TeamName string `bun:"team_name"` TeamColor string `bun:"team_color"` Goals int `bun:"total_goals"` PeriodsPlayed int `bun:"total_periods_played"` Shots int `bun:"total_shots"` } // GetTopGoalScorers returns the top goal scorers for a season-league, // sorted by goals DESC, periods ASC, shots ASC. // Stats are combined across all teams a player may have played on, // and the player's current roster team is shown. func GetTopGoalScorers( ctx context.Context, tx bun.Tx, seasonID, leagueID int, ) ([]*LeagueTopGoalScorer, error) { if seasonID == 0 { return nil, errors.New("seasonID not provided") } if leagueID == 0 { return nil, errors.New("leagueID not provided") } var stats []*LeagueTopGoalScorer err := tx.NewRaw(` SELECT agg.player_id, agg.player_name, COALESCE(tr.team_id, 0) AS team_id, COALESCE(t.name, '') AS team_name, COALESCE(t.color, '') AS team_color, agg.total_goals, agg.total_periods_played, agg.total_shots FROM ( SELECT frps.player_id AS player_id, COALESCE(p.name, frps.player_username) AS player_name, COALESCE(SUM(frps.goals), 0) AS total_goals, COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, COALESCE(SUM(frps.shots), 0) AS total_shots FROM fixture_result_player_stats frps JOIN fixture_results fr ON fr.id = frps.fixture_result_id JOIN fixtures f ON f.id = fr.fixture_id LEFT JOIN players p ON p.id = frps.player_id WHERE fr.finalized = true AND f.season_id = ? AND f.league_id = ? AND f.round > 0 AND frps.period_num = 3 AND frps.player_id IS NOT NULL GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) ORDER BY total_goals DESC, total_periods_played ASC, total_shots ASC LIMIT 10 ) agg LEFT JOIN team_rosters tr ON tr.player_id = agg.player_id AND tr.season_id = ? AND tr.league_id = ? LEFT JOIN teams t ON t.id = tr.team_id ORDER BY agg.total_goals DESC, agg.total_periods_played ASC, agg.total_shots ASC `, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats) if err != nil { return nil, errors.Wrap(err, "tx.NewRaw") } return stats, nil } // LeagueTopAssister holds aggregated assist stats for a player in a season-league. type LeagueTopAssister struct { PlayerID int `bun:"player_id"` PlayerName string `bun:"player_name"` TeamID int `bun:"team_id"` TeamName string `bun:"team_name"` TeamColor string `bun:"team_color"` Assists int `bun:"total_assists"` PeriodsPlayed int `bun:"total_periods_played"` PrimaryAssists int `bun:"total_primary_assists"` } // GetTopAssisters returns the top assisters for a season-league, // sorted by assists DESC, periods ASC, primary assists DESC. // Stats are combined across all teams a player may have played on, // and the player's current roster team is shown. func GetTopAssisters( ctx context.Context, tx bun.Tx, seasonID, leagueID int, ) ([]*LeagueTopAssister, error) { if seasonID == 0 { return nil, errors.New("seasonID not provided") } if leagueID == 0 { return nil, errors.New("leagueID not provided") } var stats []*LeagueTopAssister err := tx.NewRaw(` SELECT agg.player_id, agg.player_name, COALESCE(tr.team_id, 0) AS team_id, COALESCE(t.name, '') AS team_name, COALESCE(t.color, '') AS team_color, agg.total_assists, agg.total_periods_played, agg.total_primary_assists FROM ( SELECT frps.player_id AS player_id, COALESCE(p.name, frps.player_username) AS player_name, COALESCE(SUM(frps.assists), 0) AS total_assists, COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, COALESCE(SUM(frps.primary_assists), 0) AS total_primary_assists FROM fixture_result_player_stats frps JOIN fixture_results fr ON fr.id = frps.fixture_result_id JOIN fixtures f ON f.id = fr.fixture_id LEFT JOIN players p ON p.id = frps.player_id WHERE fr.finalized = true AND f.season_id = ? AND f.league_id = ? AND f.round > 0 AND frps.period_num = 3 AND frps.player_id IS NOT NULL GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) ORDER BY total_assists DESC, total_periods_played ASC, total_primary_assists DESC LIMIT 10 ) agg LEFT JOIN team_rosters tr ON tr.player_id = agg.player_id AND tr.season_id = ? AND tr.league_id = ? LEFT JOIN teams t ON t.id = tr.team_id ORDER BY agg.total_assists DESC, agg.total_periods_played ASC, agg.total_primary_assists DESC `, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats) if err != nil { return nil, errors.Wrap(err, "tx.NewRaw") } return stats, nil } // LeagueTopSaver holds aggregated save stats for a player in a season-league. type LeagueTopSaver struct { PlayerID int `bun:"player_id"` PlayerName string `bun:"player_name"` TeamID int `bun:"team_id"` TeamName string `bun:"team_name"` TeamColor string `bun:"team_color"` Saves int `bun:"total_saves"` PeriodsPlayed int `bun:"total_periods_played"` Blocks int `bun:"total_blocks"` } // GetTopSavers returns the top savers for a season-league, // sorted by saves DESC, periods ASC, blocks DESC. // Stats are combined across all teams a player may have played on, // and the player's current roster team is shown. func GetTopSavers( ctx context.Context, tx bun.Tx, seasonID, leagueID int, ) ([]*LeagueTopSaver, error) { if seasonID == 0 { return nil, errors.New("seasonID not provided") } if leagueID == 0 { return nil, errors.New("leagueID not provided") } var stats []*LeagueTopSaver err := tx.NewRaw(` SELECT agg.player_id, agg.player_name, COALESCE(tr.team_id, 0) AS team_id, COALESCE(t.name, '') AS team_name, COALESCE(t.color, '') AS team_color, agg.total_saves, agg.total_periods_played, agg.total_blocks FROM ( SELECT frps.player_id AS player_id, COALESCE(p.name, frps.player_username) AS player_name, COALESCE(SUM(frps.saves), 0) AS total_saves, COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, COALESCE(SUM(frps.blocks), 0) AS total_blocks FROM fixture_result_player_stats frps JOIN fixture_results fr ON fr.id = frps.fixture_result_id JOIN fixtures f ON f.id = fr.fixture_id LEFT JOIN players p ON p.id = frps.player_id WHERE fr.finalized = true AND f.season_id = ? AND f.league_id = ? AND f.round > 0 AND frps.period_num = 3 AND frps.player_id IS NOT NULL GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) ORDER BY total_saves DESC, total_periods_played ASC, total_blocks DESC LIMIT 10 ) agg LEFT JOIN team_rosters tr ON tr.player_id = agg.player_id AND tr.season_id = ? AND tr.league_id = ? LEFT JOIN teams t ON t.id = tr.team_id ORDER BY agg.total_saves DESC, agg.total_periods_played ASC, agg.total_blocks DESC `, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats) if err != nil { return nil, errors.Wrap(err, "tx.NewRaw") } return stats, nil } // TeamRecord holds win/loss/draw record and goal totals for a team. type TeamRecord struct { Played int Wins int OvertimeWins int OvertimeLosses int Losses int Draws int GoalsFor int GoalsAgainst int Points int } // Point values for the leaderboard scoring system. const ( PointsWin = 3 PointsOvertimeWin = 2 PointsOvertimeLoss = 1 PointsLoss = 0 ) // ComputeTeamRecord calculates W-OTW-OTL-L, GF/GA, and points from fixtures and results. // Points: Win=3, OT Win=2, OT Loss=1, Loss=0. // Forfeits: Outright = Win(3)/Loss(0), Mutual = OT Loss(1) for both teams. func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*FixtureResult) *TeamRecord { rec := &TeamRecord{} for _, f := range fixtures { res, ok := resultMap[f.ID] if !ok { continue } rec.Played++ isHome := f.HomeTeamID == teamID // Handle forfeits separately if res.IsForfeit { // Forfeits have 0-0 score, no goal impact if res.ForfeitType != nil && *res.ForfeitType == ForfeitTypeMutual { // Mutual forfeit: both teams get OT loss (1 point) rec.OvertimeLosses++ rec.Points += PointsOvertimeLoss } else if res.ForfeitType != nil && *res.ForfeitType == ForfeitTypeOutright { // Outright forfeit: check if this team forfeited thisSide := "away" if isHome { thisSide = "home" } if res.ForfeitTeam != nil && *res.ForfeitTeam == thisSide { // This team forfeited - loss rec.Losses++ rec.Points += PointsLoss } else { // Opponent forfeited - win rec.Wins++ rec.Points += PointsWin } } continue } // Normal match handling if isHome { rec.GoalsFor += res.HomeScore rec.GoalsAgainst += res.AwayScore } else { rec.GoalsFor += res.AwayScore rec.GoalsAgainst += res.HomeScore } won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away") lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home") isOT := strings.EqualFold(res.EndReason, "Overtime") switch { case won && isOT: rec.OvertimeWins++ rec.Points += PointsOvertimeWin case won: rec.Wins++ rec.Points += PointsWin case lost && isOT: rec.OvertimeLosses++ rec.Points += PointsOvertimeLoss case lost: rec.Losses++ rec.Points += PointsLoss default: rec.Draws++ } } return rec } // LeaderboardEntry represents a single team's standing in the league table. type LeaderboardEntry struct { Position int Team *Team Record *TeamRecord } // ComputeLeaderboard builds a sorted leaderboard from teams, fixtures, and results. // Teams are sorted by: Points DESC, Goal Differential DESC, Goals For DESC, Name ASC. func ComputeLeaderboard(teams []*Team, fixtures []*Fixture, resultMap map[int]*FixtureResult) []*LeaderboardEntry { entries := make([]*LeaderboardEntry, 0, len(teams)) // Build a map of team ID -> fixtures involving that team teamFixtures := make(map[int][]*Fixture) for _, f := range fixtures { teamFixtures[f.HomeTeamID] = append(teamFixtures[f.HomeTeamID], f) teamFixtures[f.AwayTeamID] = append(teamFixtures[f.AwayTeamID], f) } for _, team := range teams { record := ComputeTeamRecord(team.ID, teamFixtures[team.ID], resultMap) entries = append(entries, &LeaderboardEntry{ Team: team, Record: record, }) } // Sort: Points DESC, then goal diff DESC, then GF DESC, then name ASC sort.Slice(entries, func(i, j int) bool { ri, rj := entries[i].Record, entries[j].Record if ri.Points != rj.Points { return ri.Points > rj.Points } diffI := ri.GoalsFor - ri.GoalsAgainst diffJ := rj.GoalsFor - rj.GoalsAgainst if diffI != diffJ { return diffI > diffJ } if ri.GoalsFor != rj.GoalsFor { return ri.GoalsFor > rj.GoalsFor } return entries[i].Team.Name < entries[j].Team.Name }) // Assign positions for i := range entries { entries[i].Position = i + 1 } return entries } // GetFixtureTeamRosters returns all team players with participation status for a fixture. // Returns: map["home"|"away"] -> []*PlayerWithPlayStatus func GetFixtureTeamRosters( ctx context.Context, tx bun.Tx, fixture *Fixture, result *FixtureResult, ) (map[string][]*PlayerWithPlayStatus, error) { if fixture == nil { return nil, errors.New("fixture cannot be nil") } rosters := map[string][]*PlayerWithPlayStatus{} // Get home team roster homeRosters := []*TeamRoster{} err := tx.NewSelect(). Model(&homeRosters). Where("tr.team_id = ?", fixture.HomeTeamID). Where("tr.season_id = ?", fixture.SeasonID). Where("tr.league_id = ?", fixture.LeagueID). Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery { return q.Relation("User") }). Scan(ctx) if err != nil { return nil, errors.Wrap(err, "tx.NewSelect home roster") } // Get away team roster awayRosters := []*TeamRoster{} err = tx.NewSelect(). Model(&awayRosters). Where("tr.team_id = ?", fixture.AwayTeamID). Where("tr.season_id = ?", fixture.SeasonID). Where("tr.league_id = ?", fixture.LeagueID). Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery { return q.Relation("User") }). Scan(ctx) if err != nil { return nil, errors.Wrap(err, "tx.NewSelect away roster") } // Build maps of player IDs that played and their period 3 stats playedPlayerIDs := map[int]bool{} playerStatsByID := map[int]*FixtureResultPlayerStats{} freeAgentPlayerIDs := map[int]bool{} // Track free agents by team side for roster inclusion freeAgentsByTeam := map[string]map[int]*FixtureResultPlayerStats{} // "home"/"away" -> playerID -> stats freeAgentsByTeam["home"] = map[int]*FixtureResultPlayerStats{} freeAgentsByTeam["away"] = map[int]*FixtureResultPlayerStats{} if result != nil { for _, ps := range result.PlayerStats { if ps.PlayerID != nil { playedPlayerIDs[*ps.PlayerID] = true if ps.PeriodNum == 3 { playerStatsByID[*ps.PlayerID] = ps } if ps.IsFreeAgent { freeAgentPlayerIDs[*ps.PlayerID] = true if ps.PeriodNum == 3 { freeAgentsByTeam[ps.Team][*ps.PlayerID] = ps } } } } } // Build a set of roster player IDs so we can skip them when adding free agents rosterPlayerIDs := map[int]bool{} for _, r := range homeRosters { if r.Player != nil { rosterPlayerIDs[r.Player.ID] = true } } for _, r := range awayRosters { if r.Player != nil { rosterPlayerIDs[r.Player.ID] = true } } // Build home roster with play status and stats for _, r := range homeRosters { played := false var stats *FixtureResultPlayerStats if result != nil && r.Player != nil { played = playedPlayerIDs[r.Player.ID] stats = playerStatsByID[r.Player.ID] } rosters["home"] = append(rosters["home"], &PlayerWithPlayStatus{ Player: r.Player, Played: played, IsManager: r.IsManager, Stats: stats, }) } // Build away roster with play status and stats for _, r := range awayRosters { played := false var stats *FixtureResultPlayerStats if result != nil && r.Player != nil { played = playedPlayerIDs[r.Player.ID] stats = playerStatsByID[r.Player.ID] } rosters["away"] = append(rosters["away"], &PlayerWithPlayStatus{ Player: r.Player, Played: played, IsManager: r.IsManager, Stats: stats, }) } // Add free agents who played but are not on the team roster for team, faStats := range freeAgentsByTeam { for playerID, stats := range faStats { if rosterPlayerIDs[playerID] { continue // Already on the roster, skip } if stats.Player == nil { // Try to load the player player, err := GetPlayer(ctx, tx, playerID) if err != nil { continue // Skip if we can't load } stats.Player = player } rosters[team] = append(rosters[team], &PlayerWithPlayStatus{ Player: stats.Player, Played: true, IsManager: false, IsFreeAgent: true, Stats: stats, }) } } return rosters, nil }