added stats to team page

This commit is contained in:
2026-02-21 22:55:46 +11:00
parent 194391b897
commit daeb19cb82
4 changed files with 313 additions and 22 deletions

View File

@@ -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(

View File

@@ -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;

View File

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

View File

@@ -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,
<!-- Top row: Roster (left) + Fixtures (right) -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
@TeamRosterSection(twr, available)
@teamFixturesPane(twr.Team, fixtures, scheduleMap)
@teamFixturesPane(twr.Team, fixtures, scheduleMap, resultMap)
</div>
<!-- Stats below both -->
<div class="mt-6">
@teamStatsSection()
@teamStatsSection(record, playerStats)
</div>
</div>
</div>
@@ -394,31 +396,68 @@ templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPl
</script>
}
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]
}
}}
<section class="space-y-6">
<!-- Results -->
<div>
<h2 class="text-2xl font-bold text-text mb-4">Results</h2>
if len(recentResults) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No results yet.</p>
<p class="text-subtext1 text-sm mt-2">Match results will appear here once games are played.</p>
</div>
} else {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
for _, fixture := range recentResults {
@teamResultRow(team, fixture, resultMap)
}
</div>
}
</div>
<!-- Upcoming -->
<div>
<h2 class="text-2xl font-bold text-text mb-4">Upcoming</h2>
if len(fixtures) == 0 {
if len(upcoming) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No upcoming fixtures.</p>
</div>
} else {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
for _, fixture := range fixtures {
for _, fixture := range upcoming {
@teamFixtureRow(team, fixture, scheduleMap)
}
</div>
}
</div>
<!-- Results -->
<div>
<h2 class="text-2xl font-bold text-text mb-4">Results</h2>
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">Results coming soon.</p>
<p class="text-subtext1 text-sm mt-2">Match results will appear here once game data is recorded.</p>
</div>
</div>
</section>
}
@@ -468,14 +507,135 @@ templ teamFixtureRow(team *db.Team, fixture *db.Fixture, scheduleMap map[int]*db
</a>
}
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
}}
<a
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
class="px-4 py-3 flex items-center justify-between gap-3 hover:bg-surface1 transition hover:cursor-pointer block"
>
<div class="flex items-center gap-3 min-w-0">
if won {
<span class="text-xs font-bold px-2 py-0.5 bg-green/20 text-green rounded shrink-0">W</span>
} else if lost {
<span class="text-xs font-bold px-2 py-0.5 bg-red/20 text-red rounded shrink-0">L</span>
} else {
<span class="text-xs font-bold px-2 py-0.5 bg-surface1 text-subtext0 rounded shrink-0">D</span>
}
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded shrink-0">
GW{ fmt.Sprint(*fixture.GameWeek) }
</span>
if isHome {
<span class="text-xs px-2 py-0.5 bg-blue/20 text-blue rounded font-medium shrink-0">
HOME
</span>
} else {
<span class="text-xs px-2 py-0.5 bg-surface1 text-subtext0 rounded font-medium shrink-0">
AWAY
</span>
}
<span class="text-sm text-subtext0 shrink-0">vs</span>
<span class="text-text font-medium truncate">
{ opponent }
</span>
</div>
<span class="flex items-center gap-2 shrink-0">
if res.Winner == "home" {
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
} else if res.Winner == "away" {
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
} else {
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
}
</span>
</a>
}
templ teamStatsSection(record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) {
<section>
<div class="mb-4">
<h2 class="text-2xl font-bold text-text">Stats</h2>
</div>
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">Stats coming soon.</p>
<p class="text-subtext1 text-sm mt-2">Team statistics will appear here once game data is available.</p>
</div>
if record.Played == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No stats yet.</p>
<p class="text-subtext1 text-sm mt-2">Team statistics will appear here once games are played.</p>
</div>
} else {
<!-- Team Record Summary -->
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden mb-4">
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 divide-x divide-surface1">
@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), "")
</div>
</div>
<!-- Player Stats Leaderboard -->
if len(playerStats) > 0 {
<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-3 py-2 text-left text-xs font-semibold text-text">Player</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Games Played">GP</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th>
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for _, ps := range playerStats {
<tr class="hover:bg-surface1 transition-colors">
<td class="px-3 py-2 text-sm text-text">{ ps.PlayerName }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.GamesPlayed) }</td>
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ fmt.Sprint(ps.Score) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Goals) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Assists) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Saves) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Shots) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Blocks) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Passes) }</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</section>
}
templ statCell(label string, value string, valueColor string) {
<div class="px-4 py-3 text-center">
<p class="text-xs text-subtext0 font-medium uppercase mb-1">{ label }</p>
<p class={ "text-lg font-bold", templ.KV("text-text", valueColor == ""), templ.KV(valueColor, valueColor != "") }>
{ value }
</p>
</div>
}