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,
No results yet.
+Match results will appear here once games are played.
+No upcoming fixtures.
Results coming soon.
-Match results will appear here once game data is recorded.
-Stats coming soon.
-Team statistics will appear here once game data is available.
-No stats yet.
+Team statistics will appear here once games are played.
+| Player | +GP | +SC | +G | +A | +SV | +SH | +BL | +PA | +
|---|---|---|---|---|---|---|---|---|
| { 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) } | +
{ label }
++ { value } +
+