From 4185ab58e2b237e1423e6455fb973eff5fd75757 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sat, 21 Feb 2026 23:11:57 +1100 Subject: [PATCH] added league table --- .gitignore | 1 - internal/db/fixture.go | 16 +++ internal/db/fixture_result.go | 97 +++++++++++++-- internal/embedfs/web/css/output.css | 6 + internal/handlers/season_league_table.go | 32 ++++- .../seasonsview/season_league_table.templ | 112 +++++++++++++++++- 6 files changed, 242 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 9a41aa3..7a77d4d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ internal/view/**/*_templ.go internal/view/**/*_templ.txt cmd/test/* .opencode -Matches/ # Database backups (compressed) backups/*.sql.gz diff --git a/internal/db/fixture.go b/internal/db/fixture.go index 222bab0..dcfec95 100644 --- a/internal/db/fixture.go +++ b/internal/db/fixture.go @@ -127,6 +127,22 @@ func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) { Get(ctx) } +// GetAllocatedFixtures returns all fixtures with a game_week assigned for a season+league. +func GetAllocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Fixture, error) { + fixtures, err := GetList[Fixture](tx). + Where("season_id = ?", seasonID). + Where("league_id = ?", leagueID). + Where("game_week IS NOT NULL"). + Order("game_week ASC", "round ASC", "id ASC"). + Relation("HomeTeam"). + Relation("AwayTeam"). + GetAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "GetList") + } + return fixtures, nil +} + func GetFixturesForTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID int) ([]*Fixture, error) { fixtures, err := GetList[Fixture](tx). Where("season_id = ?", seasonID). diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go index 66f8c2e..4010e65 100644 --- a/internal/db/fixture_result.go +++ b/internal/db/fixture_result.go @@ -2,6 +2,8 @@ package db import ( "context" + "sort" + "strings" "time" "github.com/pkg/errors" @@ -327,15 +329,27 @@ func GetAggregatedPlayerStatsForTeam( // TeamRecord holds win/loss/draw record and goal totals for a team. type TeamRecord struct { - Played int - Wins int - Losses int - Draws int - GoalsFor int - GoalsAgainst int + Played int + Wins int + OvertimeWins int + OvertimeLosses int + Losses int + Draws int + GoalsFor int + GoalsAgainst int + Points int } -// ComputeTeamRecord calculates W-L-D and GF/GA from fixtures and results. +// 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. func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*FixtureResult) *TeamRecord { rec := &TeamRecord{} for _, f := range fixtures { @@ -354,17 +368,80 @@ func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*Fixtu } won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away") lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home") - if won { + isOT := strings.EqualFold(res.EndReason, "Overtime") + + switch { + case won && isOT: + rec.OvertimeWins++ + rec.Points += PointsOvertimeWin + case won: rec.Wins++ - } else if lost { + rec.Points += PointsWin + case lost && isOT: + rec.OvertimeLosses++ + rec.Points += PointsOvertimeLoss + case lost: rec.Losses++ - } else { + 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( diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 5b3bae0..5a70ddb 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -514,6 +514,9 @@ .w-6 { width: calc(var(--spacing) * 6); } + .w-10 { + width: calc(var(--spacing) * 10); + } .w-12 { width: calc(var(--spacing) * 12); } @@ -1294,6 +1297,9 @@ .text-subtext1 { color: var(--subtext1); } + .text-teal { + color: var(--teal); + } .text-text { color: var(--text); } diff --git a/internal/handlers/season_league_table.go b/internal/handlers/season_league_table.go index e61bd01..c870109 100644 --- a/internal/handlers/season_league_table.go +++ b/internal/handlers/season_league_table.go @@ -21,26 +21,48 @@ func SeasonLeagueTablePage( seasonStr := r.PathValue("season_short_name") leagueStr := r.PathValue("league_short_name") - var sl *db.SeasonLeague + var season *db.Season + var league *db.League + var leaderboard []*db.LeaderboardEntry if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error - sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) + var teams []*db.Team + season, league, teams, err = db.GetSeasonLeagueWithTeams(ctx, tx, seasonStr, leagueStr) if err != nil { if db.IsBadRequest(err) { throw.NotFound(s, w, r, r.URL.Path) return false, nil } - return false, errors.Wrap(err, "db.GetSeasonLeague") + return false, errors.Wrap(err, "db.GetSeasonLeagueWithTeams") } + + fixtures, err := db.GetAllocatedFixtures(ctx, tx, season.ID, league.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetAllocatedFixtures") + } + + fixtureIDs := make([]int, len(fixtures)) + for i, f := range fixtures { + fixtureIDs[i] = f.ID + } + + resultMap, err := db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs) + if err != nil { + return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures") + } + + leaderboard = db.ComputeLeaderboard(teams, fixtures, resultMap) + return true, nil }); !ok { return } + if r.Method == "GET" { - renderSafely(seasonsview.SeasonLeagueTablePage(sl.Season, sl.League), s, r, w) + renderSafely(seasonsview.SeasonLeagueTablePage(season, league, leaderboard), s, r, w) } else { - renderSafely(seasonsview.SeasonLeagueTable(), s, r, w) + renderSafely(seasonsview.SeasonLeagueTable(leaderboard), s, r, w) } }) } diff --git a/internal/view/seasonsview/season_league_table.templ b/internal/view/seasonsview/season_league_table.templ index 0a4831c..ae80916 100644 --- a/internal/view/seasonsview/season_league_table.templ +++ b/internal/view/seasonsview/season_league_table.templ @@ -1,15 +1,115 @@ package seasonsview import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" -templ SeasonLeagueTablePage(season *db.Season, league *db.League) { +templ SeasonLeagueTablePage(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) { @SeasonLeagueLayout("table", season, league) { - @SeasonLeagueTable() + @SeasonLeagueTable(leaderboard) } } -templ SeasonLeagueTable() { -
-

Coming Soon...

-
+templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) { + if len(leaderboard) == 0 { +
+

No teams in this league yet.

+
+ } else { +
+ +
+ Points: + W = { fmt.Sprint(db.PointsWin) } + OTW = { fmt.Sprint(db.PointsOvertimeWin) } + OTL = { fmt.Sprint(db.PointsOvertimeLoss) } + L = { fmt.Sprint(db.PointsLoss) } +
+
+ + + + + + + + + + + + + + + + + + for _, entry := range leaderboard { + @leaderboardRow(entry) + } + +
#TeamGPWOTWOTLLGFGAGDPTS
+
+
+ } +} + +templ leaderboardRow(entry *db.LeaderboardEntry) { + {{ + r := entry.Record + goalDiff := r.GoalsFor - r.GoalsAgainst + var gdStr string + if goalDiff > 0 { + gdStr = fmt.Sprintf("+%d", goalDiff) + } else { + gdStr = fmt.Sprint(goalDiff) + } + }} + + + { fmt.Sprint(entry.Position) } + + +
+ if entry.Team.Color != "" { + + } + { entry.Team.Name } +
+ + + { fmt.Sprint(r.Played) } + + + { fmt.Sprint(r.Wins) } + + + { fmt.Sprint(r.OvertimeWins) } + + + { fmt.Sprint(r.OvertimeLosses) } + + + { fmt.Sprint(r.Losses) } + + + { fmt.Sprint(r.GoalsFor) } + + + { fmt.Sprint(r.GoalsAgainst) } + + + if goalDiff > 0 { + { gdStr } + } else if goalDiff < 0 { + { gdStr } + } else { + { gdStr } + } + + + { fmt.Sprint(r.Points) } + + }