diff --git a/internal/db/team.go b/internal/db/team.go index 52174db..b3dde40 100644 --- a/internal/db/team.go +++ b/internal/db/team.go @@ -2,6 +2,7 @@ package db import ( "context" + "sort" "github.com/pkg/errors" "github.com/uptrace/bun" @@ -72,3 +73,149 @@ func (t *Team) InSeason(seasonID int) bool { } return false } + +// TeamSeasonInfo holds information about a team's participation in a specific season+league. +type TeamSeasonInfo struct { + Season *Season + League *League + Record *TeamRecord + TotalTeams int + Position int +} + +// GetTeamSeasonParticipation returns all season+league combos the team participated in, +// with computed records, positions, and total team counts. +func GetTeamSeasonParticipation( + ctx context.Context, + tx bun.Tx, + teamID int, +) ([]*TeamSeasonInfo, error) { + if teamID == 0 { + return nil, errors.New("teamID not provided") + } + + // Get all participations for this team + var participations []*TeamParticipation + err := tx.NewSelect(). + Model(&participations). + Where("team_id = ?", teamID). + Relation("Season", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Relation("Leagues") + }). + Relation("League"). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect participations") + } + + var results []*TeamSeasonInfo + + for _, p := range participations { + // Get all teams in this season+league for position calculation + var teams []*Team + err := tx.NewSelect(). + Model(&teams). + Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id"). + Where("tp.season_id = ? AND tp.league_id = ?", p.SeasonID, p.LeagueID). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect teams") + } + + // Get all fixtures for this season+league + fixtures, err := GetAllocatedFixtures(ctx, tx, p.SeasonID, p.LeagueID) + if err != nil { + return nil, errors.Wrap(err, "GetAllocatedFixtures") + } + + fixtureIDs := make([]int, len(fixtures)) + for i, f := range fixtures { + fixtureIDs[i] = f.ID + } + + resultMap, err := GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs) + if err != nil { + return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures") + } + + // Compute leaderboard to get position + leaderboard := ComputeLeaderboard(teams, fixtures, resultMap) + + var position int + var record *TeamRecord + for _, entry := range leaderboard { + if entry.Team.ID == teamID { + position = entry.Position + record = entry.Record + break + } + } + if record == nil { + record = &TeamRecord{} + } + + results = append(results, &TeamSeasonInfo{ + Season: p.Season, + League: p.League, + Record: record, + TotalTeams: len(teams), + Position: position, + }) + } + + // Sort by season start date descending (newest first) + sort.Slice(results, func(i, j int) bool { + return results[i].Season.StartDate.After(results[j].Season.StartDate) + }) + + return results, nil +} + +// TeamAllTimePlayerStats holds aggregated all-time stats for a player on a team. +type TeamAllTimePlayerStats struct { + PlayerID int `bun:"player_id"` + PlayerName string `bun:"player_name"` + SeasonsPlayed int `bun:"seasons_played"` + PeriodsPlayed int `bun:"total_periods_played"` + Goals int `bun:"total_goals"` + Assists int `bun:"total_assists"` + Saves int `bun:"total_saves"` +} + +// GetTeamAllTimePlayerStats returns aggregated all-time stats for all players +// who have ever played for a given team across all seasons. +func GetTeamAllTimePlayerStats( + ctx context.Context, + tx bun.Tx, + teamID int, +) ([]*TeamAllTimePlayerStats, error) { + if teamID == 0 { + return nil, errors.New("teamID not provided") + } + + var stats []*TeamAllTimePlayerStats + err := tx.NewRaw(` + SELECT + frps.player_id AS player_id, + COALESCE(p.name, frps.player_username) AS player_name, + COUNT(DISTINCT s.id) AS seasons_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 + 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 + JOIN seasons s ON s.id = f.season_id + LEFT JOIN players p ON p.id = frps.player_id + WHERE fr.finalized = true + AND frps.team_id = ? + AND frps.period_num = 3 + AND frps.player_id IS NOT NULL + GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) + `, teamID).Scan(ctx, &stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} diff --git a/internal/handlers/team_detail.go b/internal/handlers/team_detail.go new file mode 100644 index 0000000..7b607db --- /dev/null +++ b/internal/handlers/team_detail.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/throw" + teamsview "git.haelnorr.com/h/oslstats/internal/view/teamsview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// TeamDetailPage renders the global team detail page showing cross-season stats +func TeamDetailPage( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + teamIDStr := r.PathValue("team_id") + + teamID, err := strconv.Atoi(teamIDStr) + if err != nil { + throw.NotFound(s, w, r, r.URL.Path) + return + } + + var team *db.Team + var seasonInfos []*db.TeamSeasonInfo + var playerStats []*db.TeamAllTimePlayerStats + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + team, err = db.GetTeam(ctx, tx, teamID) + if err != nil { + if db.IsBadRequest(err) { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return false, errors.Wrap(err, "db.GetTeam") + } + + seasonInfos, err = db.GetTeamSeasonParticipation(ctx, tx, teamID) + if err != nil { + return false, errors.Wrap(err, "db.GetTeamSeasonParticipation") + } + + playerStats, err = db.GetTeamAllTimePlayerStats(ctx, tx, teamID) + if err != nil { + return false, errors.Wrap(err, "db.GetTeamAllTimePlayerStats") + } + + return true, nil + }); !ok { + return + } + + activeTab := r.URL.Query().Get("tab") + if activeTab != "seasons" && activeTab != "stats" { + activeTab = "seasons" + } + + renderSafely(teamsview.DetailPage(team, seasonInfos, playerStats, activeTab), s, r, w) + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 38fda14..9d797c5 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -316,6 +316,11 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.TeamsManagePlayers)(handlers.ManageTeamRoster(s, conn)), }, + { + Path: "/teams/{team_id}", + Method: hws.MethodGET, + Handler: handlers.TeamDetailPage(s, conn), + }, } htmxRoutes := []hws.Route{ diff --git a/internal/view/seasonsview/season_league_team_detail.templ b/internal/view/seasonsview/season_league_team_detail.templ index 83a0b84..c4420d0 100644 --- a/internal/view/seasonsview/season_league_team_detail.templ +++ b/internal/view/seasonsview/season_league_team_detail.templ @@ -42,13 +42,22 @@ templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, - - Back to Teams - +
diff --git a/internal/view/teamsview/detail_page.templ b/internal/view/teamsview/detail_page.templ new file mode 100644 index 0000000..937bb26 --- /dev/null +++ b/internal/view/teamsview/detail_page.templ @@ -0,0 +1,81 @@ +package teamsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "fmt" + +templ DetailPage(team *db.Team, seasonInfos []*db.TeamSeasonInfo, playerStats []*db.TeamAllTimePlayerStats, activeTab string) { + @baseview.Layout(team.Name) { +No player stats yet.
+Player statistics will appear here once games are played.
+| # | +Player | +SZN | +PP | +{ statShort } | +||
|---|---|---|---|---|---|---|
| + { fmt.Sprint(i + 1) } + | +{ ps.PlayerName } | +{ fmt.Sprint(ps.SeasonsPlayed) } | +{ fmt.Sprint(ps.PeriodsPlayed) } | + if statType == "goals" { +{ fmt.Sprint(ps.Goals) } | + } else if statType == "assists" { +{ fmt.Sprint(ps.Assists) } | + } else { +{ fmt.Sprint(ps.Saves) } | + } +
No season history yet.
+This team has not participated in any seasons.
+{ fmt.Sprint(info.Record.Points) }
+W
+{ fmt.Sprint(info.Record.Wins) }
+OTW
+{ fmt.Sprint(info.Record.OvertimeWins) }
+OTL
+{ fmt.Sprint(info.Record.OvertimeLosses) }
+L
+{ fmt.Sprint(info.Record.Losses) }
+