added player stats to profile
This commit is contained in:
@@ -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").
|
||||
|
||||
@@ -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;
|
||||
|
||||
93
internal/handlers/player_stats_filter.go
Normal file
93
internal/handlers/player_stats_filter.go
Normal file
@@ -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=<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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
<div class="max-w-screen-2xl mx-auto px-4 py-8">
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-text">{ player.DisplayName() }</h1>
|
||||
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
||||
if player.SlapID != nil {
|
||||
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||||
Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) }
|
||||
</span>
|
||||
}
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h1 class="text-4xl font-bold text-text">{ player.DisplayName() }</h1>
|
||||
if isOwner {
|
||||
<span class="px-2 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium">
|
||||
Your Profile
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
if player.SlapID != nil {
|
||||
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||||
Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
@SlapIDSection(player, isOwner)
|
||||
</div>
|
||||
<!-- SlapID Link Prompt (if needed) -->
|
||||
if player.SlapID == nil && isOwner {
|
||||
<div class="px-6 pt-6">
|
||||
@SlapIDSection(player, isOwner)
|
||||
</div>
|
||||
}
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="bg-surface0 border-b border-surface1" data-tab-nav="player-content">
|
||||
<ul class="flex flex-wrap">
|
||||
@playerNavItem("stats", "Stats", activeSection, player)
|
||||
@playerNavItem("teams", "Teams", activeSection, player)
|
||||
@playerNavItem("seasons", "Seasons", activeSection, player)
|
||||
</ul>
|
||||
</nav>
|
||||
<!-- Content Area -->
|
||||
<main class="bg-crust p-6" id="player-content">
|
||||
{ children... }
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/tabs.js" defer></script>
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}}
|
||||
<li class="inline-block">
|
||||
<a
|
||||
href={ templ.SafeURL(url) }
|
||||
hx-post={ url }
|
||||
hx-target="#player-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url={ url }
|
||||
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||||
>
|
||||
{ label }
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
73
internal/view/playersview/player_seasons_tab.templ
Normal file
73
internal/view/playersview/player_seasons_tab.templ
Normal file
@@ -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 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No season history yet.</p>
|
||||
<p class="text-subtext1 text-sm mt-2">This player has not participated in any seasons.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Season</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-text">League</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Team</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-text">Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for _, info := range seasonInfos {
|
||||
<tr class="hover:bg-surface1 transition-colors">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s", info.Season.ShortName)) }
|
||||
class="text-blue hover:text-blue/80 transition"
|
||||
>
|
||||
{ info.Season.Name }
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-subtext0">
|
||||
{ info.League.Name }
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf(
|
||||
"/seasons/%s/leagues/%s/teams/%d",
|
||||
info.Season.ShortName, info.League.ShortName, info.Team.ID,
|
||||
)) }
|
||||
class="text-blue hover:text-blue/80 transition"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
if info.Team.Color != "" {
|
||||
<div
|
||||
class="w-3 h-3 rounded-full border border-surface1 shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(info.Team.Color) }
|
||||
></div>
|
||||
}
|
||||
<span>{ info.Team.Name }</span>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
if info.IsManager {
|
||||
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
Manager
|
||||
</span>
|
||||
} else {
|
||||
<span class="text-subtext1 text-xs">Player</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
130
internal/view/playersview/player_stats_tab.templ
Normal file
130
internal/view/playersview/player_stats_tab.templ
Normal file
@@ -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) {
|
||||
<div class="space-y-6" data-filter-url={ fmt.Sprintf("/players/%d/stats/filter", player.ID) }>
|
||||
<!-- Filter Controls -->
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<!-- Season Filter -->
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-subtext0 uppercase font-medium mb-1">Filter by Season</label>
|
||||
<select
|
||||
name="season_id"
|
||||
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||
onchange={ handleFilterChange("season") }
|
||||
>
|
||||
<option value="">All Seasons</option>
|
||||
for _, s := range seasons {
|
||||
<option
|
||||
value={ fmt.Sprint(s.ID) }
|
||||
selected?={ activeFilter == "season" && activeFilterID == s.ID }
|
||||
>
|
||||
{ s.Name }
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Team Filter -->
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-subtext0 uppercase font-medium mb-1">Filter by Team</label>
|
||||
<select
|
||||
name="team_id"
|
||||
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||
onchange={ handleFilterChange("team") }
|
||||
>
|
||||
<option value="">All Teams</option>
|
||||
for _, t := range teams {
|
||||
<option
|
||||
value={ fmt.Sprint(t.ID) }
|
||||
selected?={ activeFilter == "team" && activeFilterID == t.ID }
|
||||
>
|
||||
{ t.Name }
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filter Label -->
|
||||
<div class="text-sm text-subtext0">
|
||||
if activeFilter == "" {
|
||||
Showing <span class="text-text font-medium">All-Time</span> stats
|
||||
} else if activeFilter == "season" {
|
||||
Showing stats for season:
|
||||
<span class="text-text font-medium">
|
||||
{ getSeasonName(seasons, activeFilterID) }
|
||||
</span>
|
||||
} else if activeFilter == "team" {
|
||||
Showing stats for team:
|
||||
<span class="text-text font-medium">
|
||||
{ getTeamName(teams, activeFilterID) }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Stats Grid -->
|
||||
@playerStatsGrid(stats)
|
||||
</div>
|
||||
}
|
||||
|
||||
templ playerStatsGrid(stats *db.PlayerAllTimeStats) {
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
@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")
|
||||
</div>
|
||||
}
|
||||
|
||||
templ statCard(label string, value string, colorClass string) {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-4 text-center">
|
||||
<p class="text-xs text-subtext0 uppercase font-medium mb-1">{ label }</p>
|
||||
<p class={ "text-2xl font-bold", colorClass }>{ value }</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
51
internal/view/playersview/player_teams_tab.templ
Normal file
51
internal/view/playersview/player_teams_tab.templ
Normal file
@@ -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 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No team history yet.</p>
|
||||
<p class="text-subtext1 text-sm mt-2">This player has not been on any teams.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Team</th>
|
||||
<th class="px-4 py-3 text-right text-sm font-semibold text-text">Seasons Played</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for _, info := range teamInfos {
|
||||
<tr class="hover:bg-surface1 transition-colors">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/teams/%d", info.Team.ID)) }
|
||||
class="text-blue hover:text-blue/80 transition"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
if info.Team.Color != "" {
|
||||
<div
|
||||
class="w-4 h-4 rounded-full border border-surface1 shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(info.Team.Color) }
|
||||
></div>
|
||||
}
|
||||
<span>{ info.Team.Name }</span>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-subtext0 text-right">
|
||||
{ fmt.Sprint(info.SeasonsCount) }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ templ SlapIDSection(player *db.Player, isOwner bool) {
|
||||
<div id="slap-id-section">
|
||||
if player.SlapID == nil && isOwner {
|
||||
@slapIDLinkPrompt(player)
|
||||
} else if player.SlapID != nil {
|
||||
@slapIDLinked(player)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -52,26 +50,3 @@ templ slapIDLinkPrompt(player *db.Player) {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ slapIDLinked(player *db.Player) {
|
||||
<div class="bg-green/10 border border-green/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-green shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-text">
|
||||
Slapshot ID linked:
|
||||
<span class="font-mono text-subtext0">
|
||||
if player.SlapID != nil {
|
||||
{ fmt.Sprintf("%d", *player.SlapID) }
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user