diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go index 7eed94a..5a14eeb 100644 --- a/internal/db/fixture_result.go +++ b/internal/db/fixture_result.go @@ -437,6 +437,99 @@ func GetAggregatedPlayerStatsForTeam( 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 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"` diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 8d568ff..ce19615 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -705,6 +705,9 @@ .justify-end { justify-content: flex-end; } + .gap-0\.5 { + gap: calc(var(--spacing) * 0.5); + } .gap-1 { gap: calc(var(--spacing) * 1); } @@ -768,6 +771,13 @@ margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); } } + .space-y-8 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse))); + } + } .gap-x-2 { column-gap: calc(var(--spacing) * 2); } diff --git a/internal/embedfs/web/js/sortable-table.js b/internal/embedfs/web/js/sortable-table.js new file mode 100644 index 0000000..f951d2d --- /dev/null +++ b/internal/embedfs/web/js/sortable-table.js @@ -0,0 +1,36 @@ +function sortableTable(initField, initDir) { + return { + sortField: initField || "score", + sortDir: initDir || "desc", + + sort(field) { + if (this.sortField === field) { + this.sortDir = this.sortDir === "asc" ? "desc" : "asc"; + } else { + this.sortField = field; + this.sortDir = "desc"; + } + this.reorder(); + }, + + reorder() { + const tbody = this.$refs.tbody; + if (!tbody) return; + const rows = Array.from(tbody.querySelectorAll("tr")); + const field = this.sortField; + const dir = this.sortDir === "asc" ? 1 : -1; + + rows.sort((a, b) => { + const aVal = parseFloat(a.dataset[field]) || 0; + const bVal = parseFloat(b.dataset[field]) || 0; + if (aVal !== bVal) return (aVal - bVal) * dir; + // Tiebreak: alphabetical by player name + const aName = (a.dataset.name || "").toLowerCase(); + const bName = (b.dataset.name || "").toLowerCase(); + return aName < bName ? -1 : aName > bName ? 1 : 0; + }); + + rows.forEach((row) => tbody.appendChild(row)); + }, + }; +} diff --git a/internal/handlers/season_league_stats.go b/internal/handlers/season_league_stats.go index e8f6258..18add75 100644 --- a/internal/handlers/season_league_stats.go +++ b/internal/handlers/season_league_stats.go @@ -25,6 +25,7 @@ func SeasonLeagueStatsPage( 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 @@ -52,15 +53,20 @@ func SeasonLeagueStatsPage( return false, errors.Wrap(err, "db.GetTopSavers") } + allStats, err = db.GetAllLeaguePlayerStats(ctx, tx, sl.SeasonID, sl.LeagueID) + if err != nil { + return false, errors.Wrap(err, "db.GetAllLeaguePlayerStats") + } + return true, nil }); !ok { return } if r.Method == "GET" { - renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League, topGoals, topAssists, topSaves), s, r, w) + renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League, topGoals, topAssists, topSaves, allStats), s, r, w) } else { - renderSafely(seasonsview.SeasonLeagueStats(sl.Season, sl.League, topGoals, topAssists, topSaves), s, r, w) + renderSafely(seasonsview.SeasonLeagueStats(sl.Season, sl.League, topGoals, topAssists, topSaves, allStats), s, r, w) } }) } diff --git a/internal/view/seasonsview/season_league_stats.templ b/internal/view/seasonsview/season_league_stats.templ index a39dfc0..6bc7de1 100644 --- a/internal/view/seasonsview/season_league_stats.templ +++ b/internal/view/seasonsview/season_league_stats.templ @@ -10,9 +10,10 @@ templ SeasonLeagueStatsPage( topGoals []*db.LeagueTopGoalScorer, topAssists []*db.LeagueTopAssister, topSaves []*db.LeagueTopSaver, + allStats []*db.LeaguePlayerStats, ) { @SeasonLeagueLayout("stats", season, league) { - @SeasonLeagueStats(season, league, topGoals, topAssists, topSaves) + @SeasonLeagueStats(season, league, topGoals, topAssists, topSaves, allStats) } } @@ -22,28 +23,45 @@ templ SeasonLeagueStats( topGoals []*db.LeagueTopGoalScorer, topAssists []*db.LeagueTopAssister, topSaves []*db.LeagueTopSaver, + allStats []*db.LeaguePlayerStats, ) { - if len(topGoals) == 0 && len(topAssists) == 0 && len(topSaves) == 0 { + if len(topGoals) == 0 && len(topAssists) == 0 && len(topSaves) == 0 && len(allStats) == 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) +
+ + 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 Stats

+ @allStatsTable(season, league, allStats) +
+ }
} + } templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.LeagueTopGoalScorer) { -
+

Top Goal Scorers @@ -61,44 +79,42 @@ templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.Leag

No goal data available yet.

} else { -
- - - - - - - - - +
#PlayerTeamGPPSH
+ + + + + + + + + + + + for i, gs := range goals { + + + + + + + - - - 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) }
- { 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 @@ -116,44 +132,42 @@ templ topAssistersTable(season *db.Season, league *db.League, assists []*db.Leag

No assist data available yet.

} else { -
- - - - - - - - - +
#PlayerTeamAPPPA
+ + + + + + + + + + + + for i, as := range assists { + + + + + + + - - - 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) }
- { 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 @@ -171,42 +185,122 @@ templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTop

No save data available yet.

} else { -
- - - - - - - - - +
#PlayerTeamSVPPBLK
+ + + + + + + + + + + + for i, sv := range saves { + + + + + + + - - - 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) }
- { 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 allStatsTable(season *db.Season, league *db.League, allStats []*db.LeaguePlayerStats) { +
+
+ + + + + + @sortableCol("gp", "GP", "Games Played") + @sortableCol("pp", "PP", "Periods Played") + @sortableCol("score", "SC", "Score") + @sortableCol("goals", "G", "Goals") + @sortableCol("assists", "A", "Assists") + @sortableCol("pa", "PA", "Primary Assists") + @sortableCol("sa", "SA", "Secondary Assists") + @sortableCol("saves", "SV", "Saves") + @sortableCol("shots", "SH", "Shots") + @sortableCol("blocks", "BLK", "Blocks") + @sortableCol("passes", "PAS", "Passes") + + + + for _, ps := range allStats { + + + + + + + + + + + + + + + + } + +
PlayerTeam
+ @links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName) + + @teamColorName(ps.TeamID, ps.TeamName, ps.TeamColor, season, league) + { fmt.Sprint(ps.GamesPlayed) }{ fmt.Sprint(ps.PeriodsPlayed) }{ fmt.Sprint(ps.Score) }{ fmt.Sprint(ps.Goals) }{ fmt.Sprint(ps.Assists) }{ fmt.Sprint(ps.PrimaryAssists) }{ fmt.Sprint(ps.SecondaryAssists) }{ fmt.Sprint(ps.Saves) }{ fmt.Sprint(ps.Shots) }{ fmt.Sprint(ps.Blocks) }{ fmt.Sprint(ps.Passes) }
+
+
+} + +templ sortableCol(field string, label string, title string) { + + + { label } + + + +} + templ teamColorName(teamID int, teamName string, teamColor string, season *db.Season, league *db.League) { if teamID > 0 && teamName != "" { } - { teamName } + { teamName } } else {