From f81ae78b3b27cabd707fb17146bea4982c50b3de Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 21:25:05 +1100 Subject: [PATCH] added stat leaderboards --- internal/db/fixture_result.go | 216 +++++++++++++++++ internal/embedfs/web/css/output.css | 10 + internal/handlers/season_league_stats.go | 23 +- .../seasonsview/season_league_stats.templ | 222 +++++++++++++++++- 4 files changed, 464 insertions(+), 7 deletions(-) diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go index 002daf1..7eed94a 100644 --- a/internal/db/fixture_result.go +++ b/internal/db/fixture_result.go @@ -437,6 +437,222 @@ func GetAggregatedPlayerStatsForTeam( 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 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 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 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 diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 5d03bb1..8d568ff 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -2189,11 +2189,21 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } + .lg\:flex-row { + @media (width >= 64rem) { + flex-direction: row; + } + } .lg\:items-end { @media (width >= 64rem) { align-items: flex-end; } } + .lg\:items-start { + @media (width >= 64rem) { + align-items: flex-start; + } + } .lg\:justify-between { @media (width >= 64rem) { justify-content: space-between; diff --git a/internal/handlers/season_league_stats.go b/internal/handlers/season_league_stats.go index 96a64e0..e8f6258 100644 --- a/internal/handlers/season_league_stats.go +++ b/internal/handlers/season_league_stats.go @@ -22,6 +22,9 @@ func SeasonLeagueStatsPage( leagueStr := r.PathValue("league_short_name") var sl *db.SeasonLeague + var topGoals []*db.LeagueTopGoalScorer + var topAssists []*db.LeagueTopAssister + var topSaves []*db.LeagueTopSaver if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error @@ -33,15 +36,31 @@ func SeasonLeagueStatsPage( } return false, errors.Wrap(err, "db.GetSeasonLeague") } + + topGoals, err = db.GetTopGoalScorers(ctx, tx, sl.SeasonID, sl.LeagueID) + if err != nil { + return false, errors.Wrap(err, "db.GetTopGoalScorers") + } + + topAssists, err = db.GetTopAssisters(ctx, tx, sl.SeasonID, sl.LeagueID) + if err != nil { + return false, errors.Wrap(err, "db.GetTopAssisters") + } + + topSaves, err = db.GetTopSavers(ctx, tx, sl.SeasonID, sl.LeagueID) + if err != nil { + return false, errors.Wrap(err, "db.GetTopSavers") + } + return true, nil }); !ok { return } if r.Method == "GET" { - renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League), s, r, w) + renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League, topGoals, topAssists, topSaves), s, r, w) } else { - renderSafely(seasonsview.SeasonLeagueStats(), s, r, w) + renderSafely(seasonsview.SeasonLeagueStats(sl.Season, sl.League, topGoals, topAssists, topSaves), s, r, w) } }) } diff --git a/internal/view/seasonsview/season_league_stats.templ b/internal/view/seasonsview/season_league_stats.templ index b7622ff..a39dfc0 100644 --- a/internal/view/seasonsview/season_league_stats.templ +++ b/internal/view/seasonsview/season_league_stats.templ @@ -1,15 +1,227 @@ package seasonsview import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" -templ SeasonLeagueStatsPage(season *db.Season, league *db.League) { +templ SeasonLeagueStatsPage( + season *db.Season, + league *db.League, + topGoals []*db.LeagueTopGoalScorer, + topAssists []*db.LeagueTopAssister, + topSaves []*db.LeagueTopSaver, +) { @SeasonLeagueLayout("stats", season, league) { - @SeasonLeagueStats() + @SeasonLeagueStats(season, league, topGoals, topAssists, topSaves) } } -templ SeasonLeagueStats() { -
-

Coming Soon...

+templ SeasonLeagueStats( + season *db.Season, + league *db.League, + topGoals []*db.LeagueTopGoalScorer, + topAssists []*db.LeagueTopAssister, + topSaves []*db.LeagueTopSaver, +) { + if len(topGoals) == 0 && len(topAssists) == 0 && len(topSaves) == 0 { +
+

No stats available yet.

+

Player statistics will appear here once games are finalized.

+
+ } else { + +
+ +
+ @topGoalScorersTable(season, league, topGoals) + @topAssistersTable(season, league, topAssists) +
+ + @topSaversTable(season, league, topSaves) +
+ } +} + +templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.LeagueTopGoalScorer) { +
+
+

+ Top Goal Scorers +

+
+ +
+ Sort: + G ↓ + PP ↑ + SH ↑ +
+ if len(goals) == 0 { +
+

No goal data available yet.

+
+ } else { +
+ + + + + + + + + + + + + for i, gs := range goals { + + + + + + + + + } + +
#PlayerTeamGPPSH
+ { fmt.Sprint(i + 1) } + + @links.PlayerLinkFromStats(gs.PlayerID, gs.PlayerName) + + @teamColorName(gs.TeamID, gs.TeamName, gs.TeamColor, season, league) + { fmt.Sprint(gs.Goals) }{ fmt.Sprint(gs.PeriodsPlayed) }{ fmt.Sprint(gs.Shots) }
+
+ }
} + +templ topAssistersTable(season *db.Season, league *db.League, assists []*db.LeagueTopAssister) { +
+
+

+ Top Assisters +

+
+ +
+ Sort: + A ↓ + PP ↑ + PA ↓ +
+ if len(assists) == 0 { +
+

No assist data available yet.

+
+ } else { +
+ + + + + + + + + + + + + for i, as := range assists { + + + + + + + + + } + +
#PlayerTeamAPPPA
+ { fmt.Sprint(i + 1) } + + @links.PlayerLinkFromStats(as.PlayerID, as.PlayerName) + + @teamColorName(as.TeamID, as.TeamName, as.TeamColor, season, league) + { fmt.Sprint(as.Assists) }{ fmt.Sprint(as.PeriodsPlayed) }{ fmt.Sprint(as.PrimaryAssists) }
+
+ } +
+} + +templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTopSaver) { +
+
+

+ Top Saves +

+
+ +
+ Sort: + SV ↓ + PP ↑ + BLK ↓ +
+ if len(saves) == 0 { +
+

No save data available yet.

+
+ } else { +
+ + + + + + + + + + + + + for i, sv := range saves { + + + + + + + + + } + +
#PlayerTeamSVPPBLK
+ { fmt.Sprint(i + 1) } + + @links.PlayerLinkFromStats(sv.PlayerID, sv.PlayerName) + + @teamColorName(sv.TeamID, sv.TeamName, sv.TeamColor, season, league) + { fmt.Sprint(sv.Saves) }{ fmt.Sprint(sv.PeriodsPlayed) }{ fmt.Sprint(sv.Blocks) }
+
+ } +
+} + +templ teamColorName(teamID int, teamName string, teamColor string, season *db.Season, league *db.League) { + if teamID > 0 && teamName != "" { + + if teamColor != "" { + + } + { teamName } + + } else { + + } +}