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 - +
+ + View All Seasons + + + 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) { +
+
+ +
+
+
+ if team.Color != "" { +
+ } +
+

{ team.Name }

+
+ + { team.ShortName } + + + { team.AltShortName } + +
+
+
+ + Back to Teams + +
+
+ + +
+ +
+ if activeTab == "seasons" { + @TeamDetailSeasons(team, seasonInfos) + } else if activeTab == "stats" { + @TeamDetailPlayerStats(playerStats) + } +
+
+ } +} + +templ teamDetailTab(section string, label string, activeTab string, team *db.Team) { + {{ + isActive := section == activeTab + 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("/teams/%d", team.ID) + if section != "seasons" { + url = fmt.Sprintf("/teams/%d?tab=%s", team.ID, section) + } + }} +
  • + + { label } + +
  • +} diff --git a/internal/view/teamsview/detail_player_stats.templ b/internal/view/teamsview/detail_player_stats.templ new file mode 100644 index 0000000..085c7cb --- /dev/null +++ b/internal/view/teamsview/detail_player_stats.templ @@ -0,0 +1,127 @@ +package teamsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" +import "sort" + +templ TeamDetailPlayerStats(playerStats []*db.TeamAllTimePlayerStats) { + if len(playerStats) == 0 { +
    +

    No player stats yet.

    +

    Player statistics will appear here once games are played.

    +
    + } else { +
    + +
    + + + +
    + +
    + @playerStatsTable(playerStats, "goals") +
    + +
    + @playerStatsTable(playerStats, "assists") +
    + +
    + @playerStatsTable(playerStats, "saves") +
    +
    + } +} + +templ playerStatsTable(playerStats []*db.TeamAllTimePlayerStats, statType string) { + {{ + // Make a copy so sorting doesn't affect other views + sorted := make([]*db.TeamAllTimePlayerStats, len(playerStats)) + copy(sorted, playerStats) + + switch statType { + case "goals": + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Goals > sorted[j].Goals + }) + case "assists": + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Assists > sorted[j].Assists + }) + case "saves": + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Saves > sorted[j].Saves + }) + } + + statLabel := "Goals" + statShort := "G" + if statType == "assists" { + statLabel = "Assists" + statShort = "A" + } else if statType == "saves" { + statLabel = "Saves" + statShort = "SV" + } + _ = statLabel + }} +
    +
    + + + + + + + + + + + + for i, ps := range sorted { + + + + + + if statType == "goals" { + + } else if statType == "assists" { + + } else { + + } + + } + +
    #PlayerSZNPP{ statShort }
    + { fmt.Sprint(i + 1) } + { ps.PlayerName }{ fmt.Sprint(ps.SeasonsPlayed) }{ fmt.Sprint(ps.PeriodsPlayed) }{ fmt.Sprint(ps.Goals) }{ fmt.Sprint(ps.Assists) }{ fmt.Sprint(ps.Saves) }
    +
    +
    +} diff --git a/internal/view/teamsview/detail_seasons.templ b/internal/view/teamsview/detail_seasons.templ new file mode 100644 index 0000000..0bcbe3c --- /dev/null +++ b/internal/view/teamsview/detail_seasons.templ @@ -0,0 +1,86 @@ +package teamsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/seasonsview" +import "fmt" + +templ TeamDetailSeasons(team *db.Team, seasonInfos []*db.TeamSeasonInfo) { + if len(seasonInfos) == 0 { +
    +

    No season history yet.

    +

    This team has not participated in any seasons.

    +
    + } else { +
    + for _, info := range seasonInfos { + @teamSeasonCard(team, info) + } +
    + } +} + +templ teamSeasonCard(team *db.Team, info *db.TeamSeasonInfo) { + {{ + detailURL := fmt.Sprintf( + "/seasons/%s/leagues/%s/teams/%d", + info.Season.ShortName, info.League.ShortName, team.ID, + ) + }} + + +
    +
    +

    { info.Season.Name }

    + + { info.League.Name } +
    + @seasonsview.StatusBadge(info.Season, true, true) +
    + +
    + +
    +
    + +
    + Position + + { fmt.Sprint(info.Position) } + + + / { fmt.Sprint(info.TotalTeams) } + +
    +
    + +
    + Points +

    { 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) }

    +
    +
    +
    +
    +} diff --git a/internal/view/teamsview/list_page.templ b/internal/view/teamsview/list_page.templ index 4d79a2c..de0eab4 100644 --- a/internal/view/teamsview/list_page.templ +++ b/internal/view/teamsview/list_page.templ @@ -7,6 +7,7 @@ import "git.haelnorr.com/h/oslstats/internal/view/sort" import "git.haelnorr.com/h/oslstats/internal/contexts" import "git.haelnorr.com/h/oslstats/internal/permissions" import "github.com/uptrace/bun" +import "fmt" templ ListPage(teams *db.List[db.Team]) { @baseview.Layout("Teams") { @@ -80,8 +81,10 @@ templ TeamsList(teams *db.List[db.Team]) {
    for _, t := range teams.Items { -
    @@ -102,7 +105,7 @@ templ TeamsList(teams *db.List[db.Team]) { { t.AltShortName }
    -
    + }