From 060301f2c21b23e95d62e0f18675d00c201ecc8f Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 20:48:21 +1100 Subject: [PATCH] added player stats to profile --- internal/db/player.go | 237 ++++++++++++++++++ internal/embedfs/web/css/output.css | 23 +- internal/handlers/player_stats_filter.go | 93 +++++++ internal/handlers/player_view.go | 156 ++++++++++-- internal/server/routes.go | 22 +- internal/view/playersview/player_page.templ | 85 ++++++- .../view/playersview/player_seasons_tab.templ | 73 ++++++ .../view/playersview/player_stats_tab.templ | 130 ++++++++++ .../view/playersview/player_teams_tab.templ | 51 ++++ .../view/playersview/slap_id_section.templ | 25 -- 10 files changed, 817 insertions(+), 78 deletions(-) create mode 100644 internal/handlers/player_stats_filter.go create mode 100644 internal/view/playersview/player_seasons_tab.templ create mode 100644 internal/view/playersview/player_stats_tab.templ create mode 100644 internal/view/playersview/player_teams_tab.templ diff --git a/internal/db/player.go b/internal/db/player.go index 7577b2d..48466e7 100644 --- a/internal/db/player.go +++ b/internal/db/player.go @@ -99,6 +99,243 @@ func UpdatePlayerSlapID(ctx context.Context, tx bun.Tx, playerID int, slapID uin return nil } +// PlayerAllTimeStats holds aggregated all-time stats for a single player +type PlayerAllTimeStats struct { + GamesPlayed int `bun:"games_played"` + PeriodsPlayed int `bun:"total_periods_played"` + Goals int `bun:"total_goals"` + Assists int `bun:"total_assists"` + Saves int `bun:"total_saves"` + Shots int `bun:"total_shots"` + Blocks int `bun:"total_blocks"` + Passes int `bun:"total_passes"` +} + +// GetPlayerAllTimeStats returns aggregated all-time stats for a player +// across all finalized fixture results (period 3 totals). +func GetPlayerAllTimeStats(ctx context.Context, tx bun.Tx, playerID int) (*PlayerAllTimeStats, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + stats := new(PlayerAllTimeStats) + err := tx.NewRaw(` + SELECT + 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.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 + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + WHERE fr.finalized = true + AND frps.player_id = ? + AND frps.period_num = 3 + `, playerID).Scan(ctx, stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + +// GetPlayerStatsBySeason returns aggregated stats for a player filtered by season. +func GetPlayerStatsBySeason(ctx context.Context, tx bun.Tx, playerID, seasonID int) (*PlayerAllTimeStats, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + if seasonID == 0 { + return nil, errors.New("seasonID not provided") + } + stats := new(PlayerAllTimeStats) + err := tx.NewRaw(` + SELECT + 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.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 + 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 + WHERE fr.finalized = true + AND frps.player_id = ? + AND frps.period_num = 3 + AND f.season_id = ? + `, playerID, seasonID).Scan(ctx, stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + +// GetPlayerStatsByTeam returns aggregated stats for a player filtered by team. +func GetPlayerStatsByTeam(ctx context.Context, tx bun.Tx, playerID, teamID int) (*PlayerAllTimeStats, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + if teamID == 0 { + return nil, errors.New("teamID not provided") + } + stats := new(PlayerAllTimeStats) + err := tx.NewRaw(` + SELECT + 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.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 + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + WHERE fr.finalized = true + AND frps.player_id = ? + AND frps.period_num = 3 + AND frps.team_id = ? + `, playerID, teamID).Scan(ctx, stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + +// PlayerTeamInfo holds a team the player has played on and how many seasons +type PlayerTeamInfo struct { + Team *Team + SeasonsCount int +} + +// GetPlayerTeams returns all teams the player has been rostered on, +// with a count of distinct seasons per team. +func GetPlayerTeams(ctx context.Context, tx bun.Tx, playerID int) ([]*PlayerTeamInfo, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + type teamRow struct { + TeamID int `bun:"team_id"` + SeasonsCount int `bun:"seasons_count"` + Name string `bun:"name"` + ShortName string `bun:"short_name"` + AltShortName string `bun:"alt_short_name"` + Color string `bun:"color"` + } + var rows []teamRow + err := tx.NewRaw(` + SELECT + t.id AS team_id, + t.name AS name, + t.short_name AS short_name, + t.alt_short_name AS alt_short_name, + t.color AS color, + COUNT(DISTINCT tr.season_id) AS seasons_count + FROM team_rosters tr + JOIN teams t ON t.id = tr.team_id + WHERE tr.player_id = ? + GROUP BY t.id, t.name, t.short_name, t.alt_short_name, t.color + ORDER BY seasons_count DESC + `, playerID).Scan(ctx, &rows) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + + var results []*PlayerTeamInfo + for _, row := range rows { + results = append(results, &PlayerTeamInfo{ + Team: &Team{ + ID: row.TeamID, + Name: row.Name, + ShortName: row.ShortName, + AltShortName: row.AltShortName, + Color: row.Color, + }, + SeasonsCount: row.SeasonsCount, + }) + } + return results, nil +} + +// PlayerSeasonInfo holds info about a player's participation in a specific season +type PlayerSeasonInfo struct { + Season *Season + League *League + Team *Team + IsManager bool +} + +// GetPlayerSeasons returns all season/league/team combos the player has been rostered in. +func GetPlayerSeasons(ctx context.Context, tx bun.Tx, playerID int) ([]*PlayerSeasonInfo, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + var rosters []*TeamRoster + err := tx.NewSelect(). + Model(&rosters). + Where("tr.player_id = ?", playerID). + Relation("Season"). + Relation("League"). + Relation("Team"). + Order("season.start_date DESC"). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect") + } + + var results []*PlayerSeasonInfo + for _, r := range rosters { + results = append(results, &PlayerSeasonInfo{ + Season: r.Season, + League: r.League, + Team: r.Team, + IsManager: r.IsManager, + }) + } + return results, nil +} + +// GetPlayerSeasonsList returns distinct seasons the player has participated in (for filter dropdowns). +func GetPlayerSeasonsList(ctx context.Context, tx bun.Tx, playerID int) ([]*Season, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + var seasons []*Season + err := tx.NewSelect(). + Model(&seasons). + Join("JOIN team_rosters tr ON tr.season_id = s.id"). + Where("tr.player_id = ?", playerID). + GroupExpr("s.id"). + Order("s.start_date DESC"). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect") + } + return seasons, nil +} + +// GetPlayerTeamsList returns distinct teams the player has played on (for filter dropdowns). +func GetPlayerTeamsList(ctx context.Context, tx bun.Tx, playerID int) ([]*Team, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + var teams []*Team + err := tx.NewSelect(). + Model(&teams). + Join("JOIN team_rosters tr ON tr.team_id = t.id"). + Where("tr.player_id = ?", playerID). + GroupExpr("t.id"). + Order("t.name ASC"). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect") + } + return teams, nil +} + func GetPlayersNotOnTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Player, error) { players, err := GetList[Player](tx).Relation("User"). Join("LEFT JOIN team_rosters tr ON tr.player_id = p.id"). diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 0901023..5d03bb1 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -906,12 +906,6 @@ .border-green { border-color: var(--green); } - .border-green\/30 { - border-color: var(--green); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--green) 30%, transparent); - } - } .border-overlay0 { border-color: var(--overlay0); } @@ -1008,12 +1002,6 @@ .bg-green { background-color: var(--green); } - .bg-green\/10 { - background-color: var(--green); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--green) 10%, transparent); - } - } .bg-green\/20 { background-color: var(--green); @supports (color: color-mix(in lab, red, red)) { @@ -1317,6 +1305,9 @@ .text-mantle { color: var(--mantle); } + .text-mauve { + color: var(--mauve); + } .text-overlay0 { color: var(--overlay0); } @@ -1338,6 +1329,9 @@ color: color-mix(in oklab, var(--red) 80%, transparent); } } + .text-sky { + color: var(--sky); + } .text-subtext0 { color: var(--subtext0); } @@ -2059,6 +2053,11 @@ scale: var(--tw-scale-x) var(--tw-scale-y); } } + .sm\:grid-cols-4 { + @media (width >= 40rem) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } .sm\:flex-row { @media (width >= 40rem) { flex-direction: row; diff --git a/internal/handlers/player_stats_filter.go b/internal/handlers/player_stats_filter.go new file mode 100644 index 0000000..531b6b5 --- /dev/null +++ b/internal/handlers/player_stats_filter.go @@ -0,0 +1,93 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + playersview "git.haelnorr.com/h/oslstats/internal/view/playersview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// PlayerStatsFilter handles HTMX POST requests to filter player stats +// by season or team. Only one filter can be active at a time. +// Query params: filter=season|team, filter_id= +func PlayerStatsFilter( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + player, _, ok := resolvePlayerAndOwner(s, conn, w, r) + if !ok { + return + } + + filterType := r.URL.Query().Get("filter") + filterIDStr := r.URL.Query().Get("filter_id") + + var stats *db.PlayerAllTimeStats + var seasons []*db.Season + var teams []*db.Team + var activeFilter string + var activeFilterID int + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + + // Load filter dropdown data + seasons, err = db.GetPlayerSeasonsList(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerSeasonsList") + } + teams, err = db.GetPlayerTeamsList(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerTeamsList") + } + + // Apply filter + filterID, _ := strconv.Atoi(filterIDStr) + switch filterType { + case "season": + if filterID > 0 { + stats, err = db.GetPlayerStatsBySeason(ctx, tx, player.ID, filterID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerStatsBySeason") + } + activeFilter = "season" + activeFilterID = filterID + } + case "team": + if filterID > 0 { + stats, err = db.GetPlayerStatsByTeam(ctx, tx, player.ID, filterID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerStatsByTeam") + } + activeFilter = "team" + activeFilterID = filterID + } + } + + // Default to all-time stats if no valid filter + if stats == nil { + stats, err = db.GetPlayerAllTimeStats(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerAllTimeStats") + } + activeFilter = "" + activeFilterID = 0 + } + + return true, nil + }); !ok { + return + } + + renderSafely(playersview.PlayerStatsTab( + player, stats, seasons, teams, + activeFilter, activeFilterID, + ), s, r, w) + }) +} diff --git a/internal/handlers/player_view.go b/internal/handlers/player_view.go index 11fbd80..a8a15aa 100644 --- a/internal/handlers/player_view.go +++ b/internal/handlers/player_view.go @@ -32,53 +32,155 @@ func ProfileRedirect( }) } -// PlayerView renders the player profile page. -// If the player has no SlapID and the viewer is the player's owner, show the link prompt. -// If the player has no SlapID and the viewer is not the owner, show 404. -func PlayerView( +// resolvePlayerAndOwner is a helper that resolves the player from the URL path +// and determines if the current user is the owner of the player. +// Returns false from the outer handler if resolution failed (404 already thrown). +func resolvePlayerAndOwner( + s *hws.Server, + conn *db.DB, + w http.ResponseWriter, + r *http.Request, +) (player *db.Player, isOwner bool, ok bool) { + playerIDStr := r.PathValue("player_id") + playerID, err := strconv.Atoi(playerIDStr) + if err != nil { + throw.NotFound(s, w, r, r.URL.Path) + return nil, false, false + } + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + player, err = db.GetPlayer(ctx, tx, playerID) + if err != nil { + if db.IsBadRequest(err) { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return false, errors.Wrap(err, "db.GetPlayer") + } + + user := db.CurrentUser(ctx) + if user != nil && player.UserID != nil && *player.UserID == user.ID { + isOwner = true + } + + return true, nil + }); !ok { + return nil, false, false + } + + // If player has no SlapID and viewer is not the owner, show 404 + if player.SlapID == nil && !isOwner { + throw.NotFound(s, w, r, r.URL.Path) + return nil, false, false + } + + return player, isOwner, true +} + +// PlayerViewStats renders the player profile page with the stats tab active. +// GET renders the full page layout. POST renders just the tab content. +func PlayerViewStats( s *hws.Server, conn *db.DB, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - playerIDStr := r.PathValue("player_id") - - playerID, err := strconv.Atoi(playerIDStr) - if err != nil { - throw.NotFound(s, w, r, r.URL.Path) + player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r) + if !ok { return } - var player *db.Player - var isOwner bool + var stats *db.PlayerAllTimeStats + var seasons []*db.Season + var teams []*db.Team if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error - player, err = db.GetPlayer(ctx, tx, playerID) + stats, err = db.GetPlayerAllTimeStats(ctx, tx, player.ID) if err != nil { - if db.IsBadRequest(err) { - throw.NotFound(s, w, r, r.URL.Path) - return false, nil - } - return false, errors.Wrap(err, "db.GetPlayer") + return false, errors.Wrap(err, "db.GetPlayerAllTimeStats") } - - // Check if the current user owns this player - user := db.CurrentUser(ctx) - if user != nil && player.UserID != nil && *player.UserID == user.ID { - isOwner = true + seasons, err = db.GetPlayerSeasonsList(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerSeasonsList") + } + teams, err = db.GetPlayerTeamsList(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerTeamsList") } - return true, nil }); !ok { return } - // If player has no SlapID and viewer is not the owner, show 404 - if player.SlapID == nil && !isOwner { - throw.NotFound(s, w, r, r.URL.Path) + if r.Method == "GET" { + renderSafely(playersview.PlayerStatsPage(player, isOwner, stats, seasons, teams), s, r, w) + } else { + renderSafely(playersview.PlayerStatsTab(player, stats, seasons, teams, "", 0), s, r, w) + } + }) +} + +// PlayerViewTeams renders the teams tab of the player profile page. +func PlayerViewTeams( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r) + if !ok { return } - renderSafely(playersview.PlayerPage(player, isOwner), s, r, w) + var teamInfos []*db.PlayerTeamInfo + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + teamInfos, err = db.GetPlayerTeams(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerTeams") + } + return true, nil + }); !ok { + return + } + + if r.Method == "GET" { + renderSafely(playersview.PlayerTeamsPage(player, isOwner, teamInfos), s, r, w) + } else { + renderSafely(playersview.PlayerTeamsTab(teamInfos), s, r, w) + } + }) +} + +// PlayerViewSeasons renders the seasons tab of the player profile page. +func PlayerViewSeasons( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r) + if !ok { + return + } + + var seasonInfos []*db.PlayerSeasonInfo + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + seasonInfos, err = db.GetPlayerSeasons(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerSeasons") + } + return true, nil + }); !ok { + return + } + + if r.Method == "GET" { + renderSafely(playersview.PlayerSeasonsPage(player, isOwner, seasonInfos), s, r, w) + } else { + renderSafely(playersview.PlayerSeasonsTab(seasonInfos), s, r, w) + } }) } diff --git a/internal/server/routes.go b/internal/server/routes.go index c74bc5b..80978fc 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -304,7 +304,27 @@ func addRoutes( { Path: "/players/{player_id}", Method: hws.MethodGET, - Handler: handlers.PlayerView(s, conn), + Handler: handlers.PlayerViewStats(s, conn), + }, + { + Path: "/players/{player_id}/stats", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.PlayerViewStats(s, conn), + }, + { + Path: "/players/{player_id}/teams", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.PlayerViewTeams(s, conn), + }, + { + Path: "/players/{player_id}/seasons", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.PlayerViewSeasons(s, conn), + }, + { + Path: "/players/{player_id}/stats/filter", + Method: hws.MethodPOST, + Handler: handlers.PlayerStatsFilter(s, conn), }, { Path: "/players/{player_id}/link-slapid", diff --git a/internal/view/playersview/player_page.templ b/internal/view/playersview/player_page.templ index c302f9b..9024d96 100644 --- a/internal/view/playersview/player_page.templ +++ b/internal/view/playersview/player_page.templ @@ -4,35 +4,94 @@ import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/view/baseview" import "fmt" -templ PlayerPage(player *db.Player, isOwner bool) { +templ PlayerLayout(activeSection string, player *db.Player, isOwner bool) { @baseview.Layout(player.DisplayName() + " - Player Profile") { -
+
-

{ player.DisplayName() }

-
- if player.SlapID != nil { - - Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) } - - } +
+

{ player.DisplayName() }

if isOwner { Your Profile }
+
+ if player.SlapID != nil { + + Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) } + + } +
- -
- @SlapIDSection(player, isOwner) -
+ + if player.SlapID == nil && isOwner { +
+ @SlapIDSection(player, isOwner) +
+ } + + + +
+ { children... } +
+ + } +} + +templ playerNavItem(section string, label string, activeSection string, player *db.Player) { + {{ + isActive := section == activeSection + baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2" + activeClasses := "border-blue text-blue font-semibold" + inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2" + url := fmt.Sprintf("/players/%d/%s", player.ID, section) + }} +
  • + + { label } + +
  • +} + +// Full page wrappers (for GET requests / direct navigation) + +templ PlayerStatsPage(player *db.Player, isOwner bool, stats *db.PlayerAllTimeStats, seasons []*db.Season, teams []*db.Team) { + @PlayerLayout("stats", player, isOwner) { + @PlayerStatsTab(player, stats, seasons, teams, "", 0) + } +} + +templ PlayerTeamsPage(player *db.Player, isOwner bool, teamInfos []*db.PlayerTeamInfo) { + @PlayerLayout("teams", player, isOwner) { + @PlayerTeamsTab(teamInfos) + } +} + +templ PlayerSeasonsPage(player *db.Player, isOwner bool, seasonInfos []*db.PlayerSeasonInfo) { + @PlayerLayout("seasons", player, isOwner) { + @PlayerSeasonsTab(seasonInfos) } } diff --git a/internal/view/playersview/player_seasons_tab.templ b/internal/view/playersview/player_seasons_tab.templ new file mode 100644 index 0000000..4be3983 --- /dev/null +++ b/internal/view/playersview/player_seasons_tab.templ @@ -0,0 +1,73 @@ +package playersview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" + +templ PlayerSeasonsTab(seasonInfos []*db.PlayerSeasonInfo) { + if len(seasonInfos) == 0 { +
    +

    No season history yet.

    +

    This player has not participated in any seasons.

    +
    + } else { +
    +
    + + + + + + + + + + + for _, info := range seasonInfos { + + + + + + + } + +
    SeasonLeagueTeamRole
    + + { info.Season.Name } + + + { info.League.Name } + + +
    + if info.Team.Color != "" { +
    + } + { info.Team.Name } +
    +
    +
    + if info.IsManager { + + Manager + + } else { + Player + } +
    +
    +
    + } +} diff --git a/internal/view/playersview/player_stats_tab.templ b/internal/view/playersview/player_stats_tab.templ new file mode 100644 index 0000000..021cd11 --- /dev/null +++ b/internal/view/playersview/player_stats_tab.templ @@ -0,0 +1,130 @@ +package playersview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" + +templ PlayerStatsTab(player *db.Player, stats *db.PlayerAllTimeStats, seasons []*db.Season, teams []*db.Team, activeFilter string, activeFilterID int) { +
    + +
    + +
    + + +
    + +
    + + +
    +
    + +
    + if activeFilter == "" { + Showing All-Time stats + } else if activeFilter == "season" { + Showing stats for season: + + { getSeasonName(seasons, activeFilterID) } + + } else if activeFilter == "team" { + Showing stats for team: + + { getTeamName(teams, activeFilterID) } + + } +
    + + @playerStatsGrid(stats) +
    +} + +templ playerStatsGrid(stats *db.PlayerAllTimeStats) { +
    + @statCard("Games Played", fmt.Sprint(stats.GamesPlayed), "text-blue") + @statCard("Goals", fmt.Sprint(stats.Goals), "text-green") + @statCard("Assists", fmt.Sprint(stats.Assists), "text-teal") + @statCard("Saves", fmt.Sprint(stats.Saves), "text-yellow") + @statCard("Shots", fmt.Sprint(stats.Shots), "text-peach") + @statCard("Blocks", fmt.Sprint(stats.Blocks), "text-mauve") + @statCard("Passes", fmt.Sprint(stats.Passes), "text-sky") + @statCard("Periods Played", fmt.Sprint(stats.PeriodsPlayed), "text-subtext0") +
    +} + +templ statCard(label string, value string, colorClass string) { +
    +

    { label }

    +

    { value }

    +
    +} + +script handleFilterChange(filterType string) { + var container = event.target.closest("[data-filter-url]") + if (!container) return + + var baseUrl = container.getAttribute("data-filter-url") + var seasonSelect = container.querySelector("select[name='season_id']") + var teamSelect = container.querySelector("select[name='team_id']") + + // Reset the other filter when one is selected + if (filterType === "season" && teamSelect) { + teamSelect.value = "" + } else if (filterType === "team" && seasonSelect) { + seasonSelect.value = "" + } + + var value = event.target.value + var url = baseUrl + if (value) { + url += "?filter=" + filterType + "&filter_id=" + value + } + + htmx.ajax("POST", url, {target: "#player-content", swap: "innerHTML"}) +} + +func getSeasonName(seasons []*db.Season, id int) string { + for _, s := range seasons { + if s.ID == id { + return s.Name + } + } + return "Unknown" +} + +func getTeamName(teams []*db.Team, id int) string { + for _, t := range teams { + if t.ID == id { + return t.Name + } + } + return "Unknown" +} diff --git a/internal/view/playersview/player_teams_tab.templ b/internal/view/playersview/player_teams_tab.templ new file mode 100644 index 0000000..7182b8a --- /dev/null +++ b/internal/view/playersview/player_teams_tab.templ @@ -0,0 +1,51 @@ +package playersview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" + +templ PlayerTeamsTab(teamInfos []*db.PlayerTeamInfo) { + if len(teamInfos) == 0 { +
    +

    No team history yet.

    +

    This player has not been on any teams.

    +
    + } else { +
    +
    + + + + + + + + + for _, info := range teamInfos { + + + + + } + +
    TeamSeasons Played
    + +
    + if info.Team.Color != "" { +
    + } + { info.Team.Name } +
    +
    +
    + { fmt.Sprint(info.SeasonsCount) } +
    +
    +
    + } +} diff --git a/internal/view/playersview/slap_id_section.templ b/internal/view/playersview/slap_id_section.templ index 2e13f0a..1be519b 100644 --- a/internal/view/playersview/slap_id_section.templ +++ b/internal/view/playersview/slap_id_section.templ @@ -7,8 +7,6 @@ templ SlapIDSection(player *db.Player, isOwner bool) {
    if player.SlapID == nil && isOwner { @slapIDLinkPrompt(player) - } else if player.SlapID != nil { - @slapIDLinked(player) }
    } @@ -52,26 +50,3 @@ templ slapIDLinkPrompt(player *db.Player) {
    } - -templ slapIDLinked(player *db.Player) { -
    -
    - - - - - Slapshot ID linked: - - if player.SlapID != nil { - { fmt.Sprintf("%d", *player.SlapID) } - } - - -
    -
    -}