From f98b7b2d88093a2c9b36033f2741fbd418942ab2 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 15 Mar 2026 13:11:26 +1100 Subject: [PATCH] finals stats moved out of regular season stats --- internal/db/fixture_result.go | 4 + internal/db/playoff_stats.go | 256 ++++++++++++++++++ internal/handlers/season_league_finals.go | 30 +- .../seasonsview/season_league_finals.templ | 66 ++++- 4 files changed, 350 insertions(+), 6 deletions(-) create mode 100644 internal/db/playoff_stats.go diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go index 5a14eeb..4cfa426 100644 --- a/internal/db/fixture_result.go +++ b/internal/db/fixture_result.go @@ -513,6 +513,7 @@ func GetAllLeaguePlayerStats( 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) @@ -583,6 +584,7 @@ func GetTopGoalScorers( 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) @@ -655,6 +657,7 @@ func GetTopAssisters( 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) @@ -727,6 +730,7 @@ func GetTopSavers( 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) diff --git a/internal/db/playoff_stats.go b/internal/db/playoff_stats.go new file mode 100644 index 0000000..51a1b22 --- /dev/null +++ b/internal/db/playoff_stats.go @@ -0,0 +1,256 @@ +package db + +import ( + "context" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// GetPlayoffPlayerStats returns aggregated player stats from playoff fixtures only +// (fixtures with round < 0) for a season-league. +// Reuses the same LeaguePlayerStats struct as regular season stats. +func GetPlayoffPlayerStats( + 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 +} + +// GetPlayoffTopGoalScorers returns the top 10 goal scorers from playoff fixtures. +func GetPlayoffTopGoalScorers( + 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 +} + +// GetPlayoffTopAssisters returns the top 10 assisters from playoff fixtures. +func GetPlayoffTopAssisters( + 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 +} + +// GetPlayoffTopSavers returns the top 10 savers from playoff fixtures. +func GetPlayoffTopSavers( + 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 +} diff --git a/internal/handlers/season_league_finals.go b/internal/handlers/season_league_finals.go index 5288d1e..e464d09 100644 --- a/internal/handlers/season_league_finals.go +++ b/internal/handlers/season_league_finals.go @@ -22,7 +22,7 @@ import ( // SeasonLeagueFinalsPage renders the finals tab of a season league page. // Displays different content based on season status: // - In Progress: "Regular Season in Progress" with optional "Begin Finals" button -// - Finals Soon/Finals/Completed: The playoff bracket +// - Finals Soon/Finals/Completed: The playoff bracket + finals stats func SeasonLeagueFinalsPage( s *hws.Server, conn *db.DB, @@ -34,6 +34,10 @@ func SeasonLeagueFinalsPage( var season *db.Season var league *db.League var bracket *db.PlayoffBracket + var topGoals []*db.LeagueTopGoalScorer + var topAssists []*db.LeagueTopAssister + var topSaves []*db.LeagueTopSaver + var allStats []*db.LeaguePlayerStats if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error @@ -54,15 +58,35 @@ func SeasonLeagueFinalsPage( return false, errors.Wrap(err, "db.GetPlayoffBracket") } + // Load playoff stats if bracket exists + if bracket != nil { + topGoals, err = db.GetPlayoffTopGoalScorers(ctx, tx, season.ID, league.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffTopGoalScorers") + } + topAssists, err = db.GetPlayoffTopAssisters(ctx, tx, season.ID, league.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffTopAssisters") + } + topSaves, err = db.GetPlayoffTopSavers(ctx, tx, season.ID, league.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffTopSavers") + } + allStats, err = db.GetPlayoffPlayerStats(ctx, tx, season.ID, league.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffPlayerStats") + } + } + return true, nil }); !ok { return } if r.Method == "GET" { - renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league, bracket), s, r, w) + renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league, bracket, topGoals, topAssists, topSaves, allStats), s, r, w) } else { - renderSafely(seasonsview.SeasonLeagueFinals(season, league, bracket), s, r, w) + renderSafely(seasonsview.SeasonLeagueFinals(season, league, bracket, topGoals, topAssists, topSaves, allStats), s, r, w) } }) } diff --git a/internal/view/seasonsview/season_league_finals.templ b/internal/view/seasonsview/season_league_finals.templ index 3742fbe..db5b006 100644 --- a/internal/view/seasonsview/season_league_finals.templ +++ b/internal/view/seasonsview/season_league_finals.templ @@ -5,20 +5,43 @@ import "git.haelnorr.com/h/oslstats/internal/contexts" import "git.haelnorr.com/h/oslstats/internal/permissions" import "fmt" -templ SeasonLeagueFinalsPage(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { +templ SeasonLeagueFinalsPage( + season *db.Season, + league *db.League, + bracket *db.PlayoffBracket, + topGoals []*db.LeagueTopGoalScorer, + topAssists []*db.LeagueTopAssister, + topSaves []*db.LeagueTopSaver, + allStats []*db.LeaguePlayerStats, +) { @SeasonLeagueLayout("finals", season, league) { - @SeasonLeagueFinals(season, league, bracket) + @SeasonLeagueFinals(season, league, bracket, topGoals, topAssists, topSaves, allStats) } } -templ SeasonLeagueFinals(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { +templ SeasonLeagueFinals( + season *db.Season, + league *db.League, + bracket *db.PlayoffBracket, + topGoals []*db.LeagueTopGoalScorer, + topAssists []*db.LeagueTopAssister, + topSaves []*db.LeagueTopSaver, + allStats []*db.LeaguePlayerStats, +) { {{ permCache := contexts.Permissions(ctx) canManagePlayoffs := permCache != nil && permCache.HasPermission(permissions.PlayoffsManage) + hasStats := len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 || len(allStats) > 0 }}
if bracket != nil { @PlayoffBracketView(season, league, bracket) + + if hasStats { +
+ @finalsStatsSection(season, league, topGoals, topAssists, topSaves, allStats) +
+ } } else if canManagePlayoffs { @finalsNotYetConfigured(season, league) } else { @@ -27,6 +50,43 @@ templ SeasonLeagueFinals(season *db.Season, league *db.League, bracket *db.Playo
} +templ finalsStatsSection( + season *db.Season, + league *db.League, + topGoals []*db.LeagueTopGoalScorer, + topAssists []*db.LeagueTopAssister, + topSaves []*db.LeagueTopSaver, + allStats []*db.LeaguePlayerStats, +) { + +
+
+ +

Finals Stats

+
+ + if len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 { +
+

Trophy Leaders

+
+
+ @topGoalScorersTable(season, league, topGoals) + @topAssistersTable(season, league, topAssists) +
+ @topSaversTable(season, league, topSaves) +
+
+ } + + if len(allStats) > 0 { +
+

All Finals Stats

+ @allStatsTable(season, league, allStats) +
+ } +
+} + templ finalsNotYetConfigured(season *db.Season, league *db.League) {