From 1c93a707abd9bf3b5979be771ca276cd68a22434 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sat, 21 Feb 2026 22:55:46 +1100 Subject: [PATCH] added stats to team page --- internal/db/fixture_result.go | 96 +++++++++ internal/embedfs/web/css/output.css | 28 ++- .../handlers/season_league_team_detail.go | 13 +- .../season_league_team_detail.templ | 198 ++++++++++++++++-- 4 files changed, 313 insertions(+), 22 deletions(-) diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go index 511417c..66f8c2e 100644 --- a/internal/db/fixture_result.go +++ b/internal/db/fixture_result.go @@ -269,6 +269,102 @@ func GetFinalizedResultsForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs [ return resultMap, nil } +// AggregatedPlayerStats holds summed stats for a player across multiple fixtures. +type AggregatedPlayerStats struct { + PlayerID int `bun:"player_id"` + PlayerName string `bun:"player_name"` + GamesPlayed int `bun:"games_played"` + Score int `bun:"total_score"` + 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"` +} + +// GetAggregatedPlayerStatsForTeam returns aggregated period-3 stats for all mapped +// players on a given team across all finalized fixture results. +func GetAggregatedPlayerStatsForTeam( + ctx context.Context, + tx bun.Tx, + teamID int, + fixtureIDs []int, +) ([]*AggregatedPlayerStats, error) { + if len(fixtureIDs) == 0 { + return nil, nil + } + + var stats []*AggregatedPlayerStats + err := tx.NewRaw(` + 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.score), 0) AS total_score, + 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 + LEFT JOIN players p ON p.id = frps.player_id + WHERE fr.finalized = true + AND fr.fixture_id IN (?) + 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) + ORDER BY total_score DESC + `, bun.In(fixtureIDs), teamID).Scan(ctx, &stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + +// TeamRecord holds win/loss/draw record and goal totals for a team. +type TeamRecord struct { + Played int + Wins int + Losses int + Draws int + GoalsFor int + GoalsAgainst int +} + +// ComputeTeamRecord calculates W-L-D and GF/GA from fixtures and results. +func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*FixtureResult) *TeamRecord { + rec := &TeamRecord{} + for _, f := range fixtures { + res, ok := resultMap[f.ID] + if !ok { + continue + } + rec.Played++ + isHome := f.HomeTeamID == teamID + if isHome { + rec.GoalsFor += res.HomeScore + rec.GoalsAgainst += res.AwayScore + } else { + rec.GoalsFor += res.AwayScore + rec.GoalsAgainst += res.HomeScore + } + won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away") + lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home") + if won { + rec.Wins++ + } else if lost { + rec.Losses++ + } else { + rec.Draws++ + } + } + return rec +} + // GetFixtureTeamRosters returns all team players with participation status for a fixture. // Returns: map["home"|"away"] -> []*PlayerWithPlayStatus func GetFixtureTeamRosters( diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 493e403..5b3bae0 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -793,6 +793,14 @@ .gap-y-5 { row-gap: calc(var(--spacing) * 5); } + .divide-x { + :where(& > :not(:last-child)) { + --tw-divide-x-reverse: 0; + border-inline-style: var(--tw-border-style); + border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); + border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); + } + } .divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; @@ -1956,6 +1964,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; @@ -2087,6 +2100,11 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } + .lg\:grid-cols-6 { + @media (width >= 64rem) { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + } .lg\:items-end { @media (width >= 64rem) { align-items: flex-end; @@ -2322,7 +2340,7 @@ inherits: false; initial-value: 0; } -@property --tw-divide-y-reverse { +@property --tw-divide-x-reverse { syntax: "*"; inherits: false; initial-value: 0; @@ -2332,6 +2350,11 @@ inherits: false; initial-value: solid; } +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} @property --tw-leading { syntax: "*"; inherits: false; @@ -2527,8 +2550,9 @@ --tw-skew-y: initial; --tw-space-y-reverse: 0; --tw-space-x-reverse: 0; - --tw-divide-y-reverse: 0; + --tw-divide-x-reverse: 0; --tw-border-style: solid; + --tw-divide-y-reverse: 0; --tw-leading: initial; --tw-font-weight: initial; --tw-tracking: initial; diff --git a/internal/handlers/season_league_team_detail.go b/internal/handlers/season_league_team_detail.go index 2c7ab71..5d55c03 100644 --- a/internal/handlers/season_league_team_detail.go +++ b/internal/handlers/season_league_team_detail.go @@ -33,6 +33,8 @@ func SeasonLeagueTeamDetailPage( var fixtures []*db.Fixture var available []*db.Player var scheduleMap map[int]*db.FixtureSchedule + var resultMap map[int]*db.FixtureResult + var playerStats []*db.AggregatedPlayerStats if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error @@ -56,6 +58,14 @@ func SeasonLeagueTeamDetailPage( if err != nil { return false, errors.Wrap(err, "db.GetAcceptedSchedulesForFixtures") } + resultMap, err = db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs) + if err != nil { + return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures") + } + playerStats, err = db.GetAggregatedPlayerStatsForTeam(ctx, tx, teamID, fixtureIDs) + if err != nil { + return false, errors.Wrap(err, "db.GetAggregatedPlayerStatsForTeam") + } available, err = db.GetPlayersNotOnTeam(ctx, tx, twr.Season.ID, twr.League.ID) if err != nil { @@ -67,6 +77,7 @@ func SeasonLeagueTeamDetailPage( return } - renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available, scheduleMap), s, r, w) + record := db.ComputeTeamRecord(teamID, fixtures, resultMap) + renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available, scheduleMap, resultMap, record, playerStats), s, r, w) }) } diff --git a/internal/view/seasonsview/season_league_team_detail.templ b/internal/view/seasonsview/season_league_team_detail.templ index 409c05a..f40dc3e 100644 --- a/internal/view/seasonsview/season_league_team_detail.templ +++ b/internal/view/seasonsview/season_league_team_detail.templ @@ -5,8 +5,10 @@ import "git.haelnorr.com/h/oslstats/internal/permissions" import "git.haelnorr.com/h/oslstats/internal/contexts" import "git.haelnorr.com/h/oslstats/internal/view/baseview" import "fmt" +import "sort" +import "time" -templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player, scheduleMap map[int]*db.FixtureSchedule) { +templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult, record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) { {{ team := twr.Team season := twr.Season @@ -54,11 +56,11 @@ templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture,
@TeamRosterSection(twr, available) - @teamFixturesPane(twr.Team, fixtures, scheduleMap) + @teamFixturesPane(twr.Team, fixtures, scheduleMap, resultMap)
- @teamStatsSection() + @teamStatsSection(record, playerStats)
@@ -394,31 +396,68 @@ templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPl } -templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) { +templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) { + {{ + // Split fixtures into upcoming and completed + var upcoming []*db.Fixture + var completed []*db.Fixture + for _, f := range fixtures { + if _, hasResult := resultMap[f.ID]; hasResult { + completed = append(completed, f) + } else { + upcoming = append(upcoming, f) + } + } + // Sort completed by scheduled time descending (most recent first) + sort.Slice(completed, func(i, j int) bool { + ti := time.Time{} + tj := time.Time{} + if si, ok := scheduleMap[completed[i].ID]; ok && si.ScheduledTime != nil { + ti = *si.ScheduledTime + } + if sj, ok := scheduleMap[completed[j].ID]; ok && sj.ScheduledTime != nil { + tj = *sj.ScheduledTime + } + return ti.After(tj) + }) + // Limit to 5 most recent results + recentResults := completed + if len(recentResults) > 5 { + recentResults = recentResults[:5] + } + }}
+ +
+

Results

+ if len(recentResults) == 0 { +
+

No results yet.

+

Match results will appear here once games are played.

+
+ } else { +
+ for _, fixture := range recentResults { + @teamResultRow(team, fixture, resultMap) + } +
+ } +

Upcoming

- if len(fixtures) == 0 { + if len(upcoming) == 0 {

No upcoming fixtures.

} else {
- for _, fixture := range fixtures { + for _, fixture := range upcoming { @teamFixtureRow(team, fixture, scheduleMap) }
}
- -
-

Results

-
-

Results coming soon.

-

Match results will appear here once game data is recorded.

-
-
} @@ -468,14 +507,135 @@ templ teamFixtureRow(team *db.Team, fixture *db.Fixture, scheduleMap map[int]*db } -templ teamStatsSection() { +templ teamResultRow(team *db.Team, fixture *db.Fixture, resultMap map[int]*db.FixtureResult) { + {{ + isHome := fixture.HomeTeamID == team.ID + var opponent string + if isHome { + opponent = fixture.AwayTeam.Name + } else { + opponent = fixture.HomeTeam.Name + } + res := resultMap[fixture.ID] + won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away") + lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home") + _ = lost + }} + +
+ if won { + W + } else if lost { + L + } else { + D + } + + GW{ fmt.Sprint(*fixture.GameWeek) } + + if isHome { + + HOME + + } else { + + AWAY + + } + vs + + { opponent } + +
+ + if res.Winner == "home" { + { fmt.Sprint(res.HomeScore) } + + { fmt.Sprint(res.AwayScore) } + } else if res.Winner == "away" { + { fmt.Sprint(res.HomeScore) } + + { fmt.Sprint(res.AwayScore) } + } else { + { fmt.Sprint(res.HomeScore) } + + { fmt.Sprint(res.AwayScore) } + } + +
+} + +templ teamStatsSection(record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) {

Stats

-
-

Stats coming soon.

-

Team statistics will appear here once game data is available.

-
+ if record.Played == 0 { +
+

No stats yet.

+

Team statistics will appear here once games are played.

+
+ } else { + +
+
+ @statCell("Played", fmt.Sprint(record.Played), "") + @statCell("Record", fmt.Sprintf("%d-%d-%d", record.Wins, record.Losses, record.Draws), "") + @statCell("Wins", fmt.Sprint(record.Wins), "text-green") + @statCell("Losses", fmt.Sprint(record.Losses), "text-red") + @statCell("GF", fmt.Sprint(record.GoalsFor), "") + @statCell("GA", fmt.Sprint(record.GoalsAgainst), "") +
+
+ + if len(playerStats) > 0 { +
+
+ + + + + + + + + + + + + + + + for _, ps := range playerStats { + + + + + + + + + + + + } + +
PlayerGPSCGASVSHBLPA
{ ps.PlayerName }{ fmt.Sprint(ps.GamesPlayed) }{ fmt.Sprint(ps.Score) }{ fmt.Sprint(ps.Goals) }{ fmt.Sprint(ps.Assists) }{ fmt.Sprint(ps.Saves) }{ fmt.Sprint(ps.Shots) }{ fmt.Sprint(ps.Blocks) }{ fmt.Sprint(ps.Passes) }
+
+
+ } + }
} + +templ statCell(label string, value string, valueColor string) { +
+

{ label }

+

+ { value } +

+
+}