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 {
-
-
-
-
- | # |
- Player |
- Team |
- G |
- PP |
- SH |
+
+
+
+ | # |
+ Player |
+ Team |
+ G |
+ PP |
+ SH |
+
+
+
+ for i, gs := range goals {
+
+ |
+ { 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) } |
-
-
- for i, gs := range goals {
-
- |
- { 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 {
-
-
-
-
- | # |
- Player |
- Team |
- A |
- PP |
- PA |
+
+
+
+ | # |
+ Player |
+ Team |
+ A |
+ PP |
+ PA |
+
+
+
+ for i, as := range assists {
+
+ |
+ { 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) } |
-
-
- for i, as := range assists {
-
- |
- { 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 {
-
-
-
-
- | # |
- Player |
- Team |
- SV |
- PP |
- BLK |
+
+
+
+ | # |
+ Player |
+ Team |
+ SV |
+ PP |
+ BLK |
+
+
+
+ for i, sv := range saves {
+
+ |
+ { 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) } |
-
-
- for i, sv := range saves {
-
- |
- { 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) {
+
+
+
+
+
+ | Player |
+ Team |
+ @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 {
+
+ |
+ @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 {
—