Compare commits

..

13 Commits

Author SHA1 Message Date
62aa9ee629 Merge branch 'stats' into development 2026-03-07 11:47:59 +11:00
8819977ebb added match preview and analysis 2026-03-06 22:08:41 +11:00
4783e21477 fixed stat sorting 2026-03-06 21:40:18 +11:00
1c03581795 added league stats 2026-03-06 21:37:02 +11:00
f81ae78b3b added stat leaderboards 2026-03-06 21:25:05 +11:00
a3abf1b1cd removed "registered by" field for free agents 2026-03-06 21:05:55 +11:00
9884793bca added better links to teams and players 2026-03-06 21:03:51 +11:00
e99f10d0f4 added player stats to profile 2026-03-06 20:48:21 +11:00
71181c43e9 player profile added 2026-03-06 19:51:27 +11:00
fc219a044c updated team stats 2026-03-06 19:06:29 +11:00
c53fbac281 added team overview 2026-03-06 18:53:58 +11:00
baa15f03fa added ot stats to team view 2026-03-06 18:29:23 +11:00
eb9fd64935 added periods played 2026-03-06 18:23:37 +11:00
34 changed files with 4151 additions and 203 deletions

View File

@@ -381,16 +381,17 @@ func GetFinalizedResultsForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs [
// AggregatedPlayerStats holds summed stats for a player across multiple fixtures. // AggregatedPlayerStats holds summed stats for a player across multiple fixtures.
type AggregatedPlayerStats struct { type AggregatedPlayerStats struct {
PlayerID int `bun:"player_id"` PlayerID int `bun:"player_id"`
PlayerName string `bun:"player_name"` PlayerName string `bun:"player_name"`
GamesPlayed int `bun:"games_played"` GamesPlayed int `bun:"games_played"`
Score int `bun:"total_score"` PeriodsPlayed int `bun:"total_periods_played"`
Goals int `bun:"total_goals"` Score int `bun:"total_score"`
Assists int `bun:"total_assists"` Goals int `bun:"total_goals"`
Saves int `bun:"total_saves"` Assists int `bun:"total_assists"`
Shots int `bun:"total_shots"` Saves int `bun:"total_saves"`
Blocks int `bun:"total_blocks"` Shots int `bun:"total_shots"`
Passes int `bun:"total_passes"` Blocks int `bun:"total_blocks"`
Passes int `bun:"total_passes"`
} }
// GetAggregatedPlayerStatsForTeam returns aggregated period-3 stats for all mapped // GetAggregatedPlayerStatsForTeam returns aggregated period-3 stats for all mapped
@@ -411,6 +412,7 @@ func GetAggregatedPlayerStatsForTeam(
frps.player_id AS player_id, frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name, COALESCE(p.name, frps.player_username) AS player_name,
COUNT(DISTINCT frps.fixture_result_id) AS games_played, COUNT(DISTINCT frps.fixture_result_id) AS games_played,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.score), 0) AS total_score, COALESCE(SUM(frps.score), 0) AS total_score,
COALESCE(SUM(frps.goals), 0) AS total_goals, COALESCE(SUM(frps.goals), 0) AS total_goals,
COALESCE(SUM(frps.assists), 0) AS total_assists, COALESCE(SUM(frps.assists), 0) AS total_assists,
@@ -435,6 +437,315 @@ func GetAggregatedPlayerStatsForTeam(
return stats, nil return stats, nil
} }
// LeaguePlayerStats holds all aggregated stats for a player in a season-league.
type LeaguePlayerStats struct {
PlayerID int `bun:"player_id"`
PlayerName string `bun:"player_name"`
TeamID int `bun:"team_id"`
TeamName string `bun:"team_name"`
TeamColor string `bun:"team_color"`
GamesPlayed int `bun:"games_played"`
PeriodsPlayed int `bun:"total_periods_played"`
Goals int `bun:"total_goals"`
Assists int `bun:"total_assists"`
PrimaryAssists int `bun:"total_primary_assists"`
SecondaryAssists int `bun:"total_secondary_assists"`
Saves int `bun:"total_saves"`
Shots int `bun:"total_shots"`
Blocks int `bun:"total_blocks"`
Passes int `bun:"total_passes"`
Score int `bun:"total_score"`
}
// GetAllLeaguePlayerStats returns aggregated stats for all players in a season-league.
// Stats are combined across all teams a player may have played on,
// and the player's current roster team is shown.
func GetAllLeaguePlayerStats(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*LeaguePlayerStats, error) {
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
if leagueID == 0 {
return nil, errors.New("leagueID not provided")
}
var stats []*LeaguePlayerStats
err := tx.NewRaw(`
SELECT
agg.player_id,
agg.player_name,
COALESCE(tr.team_id, 0) AS team_id,
COALESCE(t.name, '') AS team_name,
COALESCE(t.color, '') AS team_color,
agg.games_played,
agg.total_periods_played,
agg.total_goals,
agg.total_assists,
agg.total_primary_assists,
agg.total_secondary_assists,
agg.total_saves,
agg.total_shots,
agg.total_blocks,
agg.total_passes,
agg.total_score
FROM (
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.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.primary_assists), 0) AS total_primary_assists,
COALESCE(SUM(frps.secondary_assists), 0) AS total_secondary_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,
COALESCE(SUM(frps.score), 0) AS total_score
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
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_id = ?
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
) agg
LEFT JOIN team_rosters tr
ON tr.player_id = agg.player_id
AND tr.season_id = ?
AND tr.league_id = ?
LEFT JOIN teams t ON t.id = tr.team_id
ORDER BY agg.total_score DESC
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// LeagueTopGoalScorer holds aggregated goal scoring stats for a player in a season-league.
type LeagueTopGoalScorer struct {
PlayerID int `bun:"player_id"`
PlayerName string `bun:"player_name"`
TeamID int `bun:"team_id"`
TeamName string `bun:"team_name"`
TeamColor string `bun:"team_color"`
Goals int `bun:"total_goals"`
PeriodsPlayed int `bun:"total_periods_played"`
Shots int `bun:"total_shots"`
}
// GetTopGoalScorers returns the top goal scorers for a season-league,
// sorted by goals DESC, periods ASC, shots ASC.
// Stats are combined across all teams a player may have played on,
// and the player's current roster team is shown.
func GetTopGoalScorers(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*LeagueTopGoalScorer, error) {
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
if leagueID == 0 {
return nil, errors.New("leagueID not provided")
}
var stats []*LeagueTopGoalScorer
err := tx.NewRaw(`
SELECT
agg.player_id,
agg.player_name,
COALESCE(tr.team_id, 0) AS team_id,
COALESCE(t.name, '') AS team_name,
COALESCE(t.color, '') AS team_color,
agg.total_goals,
agg.total_periods_played,
agg.total_shots
FROM (
SELECT
frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name,
COALESCE(SUM(frps.goals), 0) AS total_goals,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.shots), 0) AS total_shots
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
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_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_goals DESC, total_periods_played ASC, total_shots ASC
LIMIT 10
) agg
LEFT JOIN team_rosters tr
ON tr.player_id = agg.player_id
AND tr.season_id = ?
AND tr.league_id = ?
LEFT JOIN teams t ON t.id = tr.team_id
ORDER BY agg.total_goals DESC, agg.total_periods_played ASC, agg.total_shots ASC
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// LeagueTopAssister holds aggregated assist stats for a player in a season-league.
type LeagueTopAssister struct {
PlayerID int `bun:"player_id"`
PlayerName string `bun:"player_name"`
TeamID int `bun:"team_id"`
TeamName string `bun:"team_name"`
TeamColor string `bun:"team_color"`
Assists int `bun:"total_assists"`
PeriodsPlayed int `bun:"total_periods_played"`
PrimaryAssists int `bun:"total_primary_assists"`
}
// GetTopAssisters returns the top assisters for a season-league,
// sorted by assists DESC, periods ASC, primary assists DESC.
// Stats are combined across all teams a player may have played on,
// and the player's current roster team is shown.
func GetTopAssisters(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*LeagueTopAssister, error) {
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
if leagueID == 0 {
return nil, errors.New("leagueID not provided")
}
var stats []*LeagueTopAssister
err := tx.NewRaw(`
SELECT
agg.player_id,
agg.player_name,
COALESCE(tr.team_id, 0) AS team_id,
COALESCE(t.name, '') AS team_name,
COALESCE(t.color, '') AS team_color,
agg.total_assists,
agg.total_periods_played,
agg.total_primary_assists
FROM (
SELECT
frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name,
COALESCE(SUM(frps.assists), 0) AS total_assists,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.primary_assists), 0) AS total_primary_assists
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
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_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_assists DESC, total_periods_played ASC, total_primary_assists DESC
LIMIT 10
) agg
LEFT JOIN team_rosters tr
ON tr.player_id = agg.player_id
AND tr.season_id = ?
AND tr.league_id = ?
LEFT JOIN teams t ON t.id = tr.team_id
ORDER BY agg.total_assists DESC, agg.total_periods_played ASC, agg.total_primary_assists DESC
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// LeagueTopSaver holds aggregated save stats for a player in a season-league.
type LeagueTopSaver struct {
PlayerID int `bun:"player_id"`
PlayerName string `bun:"player_name"`
TeamID int `bun:"team_id"`
TeamName string `bun:"team_name"`
TeamColor string `bun:"team_color"`
Saves int `bun:"total_saves"`
PeriodsPlayed int `bun:"total_periods_played"`
Blocks int `bun:"total_blocks"`
}
// GetTopSavers returns the top savers for a season-league,
// sorted by saves DESC, periods ASC, blocks DESC.
// Stats are combined across all teams a player may have played on,
// and the player's current roster team is shown.
func GetTopSavers(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*LeagueTopSaver, error) {
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
if leagueID == 0 {
return nil, errors.New("leagueID not provided")
}
var stats []*LeagueTopSaver
err := tx.NewRaw(`
SELECT
agg.player_id,
agg.player_name,
COALESCE(tr.team_id, 0) AS team_id,
COALESCE(t.name, '') AS team_name,
COALESCE(t.color, '') AS team_color,
agg.total_saves,
agg.total_periods_played,
agg.total_blocks
FROM (
SELECT
frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name,
COALESCE(SUM(frps.saves), 0) AS total_saves,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.blocks), 0) AS total_blocks
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
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_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_saves DESC, total_periods_played ASC, total_blocks DESC
LIMIT 10
) agg
LEFT JOIN team_rosters tr
ON tr.player_id = agg.player_id
AND tr.season_id = ?
AND tr.league_id = ?
LEFT JOIN teams t ON t.id = tr.team_id
ORDER BY agg.total_saves DESC, agg.total_periods_played ASC, agg.total_blocks DESC
`, seasonID, leagueID, seasonID, leagueID).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. // TeamRecord holds win/loss/draw record and goal totals for a team.
type TeamRecord struct { type TeamRecord struct {
Played int Played int

View File

@@ -0,0 +1,254 @@
package db
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// Game outcome type constants.
const (
OutcomeWin = "W"
OutcomeLoss = "L"
OutcomeOTWin = "OTW"
OutcomeOTLoss = "OTL"
OutcomeDraw = "D"
OutcomeForfeit = "F"
)
// GameOutcome represents the result of a single game from a team's perspective.
type GameOutcome struct {
Type string // One of Outcome* constants: "W", "L", "OTW", "OTL", "D", "F"
Opponent *Team // The opposing team (may be nil if relation not loaded)
Score string // e.g. "3-1" or "" for forfeits
IsForfeit bool // Whether this game was decided by forfeit
Fixture *Fixture // The fixture itself
}
// MatchPreviewData holds all computed data needed for the match preview tab.
type MatchPreviewData struct {
HomeRecord *TeamRecord
AwayRecord *TeamRecord
HomePosition int
AwayPosition int
TotalTeams int
HomeRecentGames []*GameOutcome
AwayRecentGames []*GameOutcome
}
// ComputeRecentGames calculates the last N game outcomes for a given team.
// Fixtures should be all allocated fixtures for the season+league.
// Results should be finalized results mapped by fixture ID.
// Schedules should be accepted schedules mapped by fixture ID (for ordering by scheduled time).
// The returned outcomes are in chronological order (oldest first, newest last).
func ComputeRecentGames(
teamID int,
fixtures []*Fixture,
resultMap map[int]*FixtureResult,
scheduleMap map[int]*FixtureSchedule,
limit int,
) []*GameOutcome {
// Collect fixtures involving this team that have finalized results
type fixtureWithTime struct {
fixture *Fixture
result *FixtureResult
time time.Time
}
var played []fixtureWithTime
for _, f := range fixtures {
if f.HomeTeamID != teamID && f.AwayTeamID != teamID {
continue
}
res, ok := resultMap[f.ID]
if !ok {
continue
}
// Use schedule time for ordering, fall back to result creation time
t := time.Unix(res.CreatedAt, 0)
if scheduleMap != nil {
if sched, ok := scheduleMap[f.ID]; ok && sched.ScheduledTime != nil {
t = *sched.ScheduledTime
}
}
played = append(played, fixtureWithTime{fixture: f, result: res, time: t})
}
// Sort by time descending (most recent first)
sort.Slice(played, func(i, j int) bool {
return played[i].time.After(played[j].time)
})
// Take only the most recent N
if len(played) > limit {
played = played[:limit]
}
// Reverse to chronological order (oldest first)
for i, j := 0, len(played)-1; i < j; i, j = i+1, j-1 {
played[i], played[j] = played[j], played[i]
}
// Build outcome list
outcomes := make([]*GameOutcome, len(played))
for i, p := range played {
outcomes[i] = buildGameOutcome(teamID, p.fixture, p.result)
}
return outcomes
}
// buildGameOutcome determines the outcome type for a single game from a team's perspective.
// Note: fixtures must have their HomeTeam and AwayTeam relations loaded.
func buildGameOutcome(teamID int, fixture *Fixture, result *FixtureResult) *GameOutcome {
isHome := fixture.HomeTeamID == teamID
var opponent *Team
if isHome {
opponent = fixture.AwayTeam // may be nil if relation not loaded
} else {
opponent = fixture.HomeTeam // may be nil if relation not loaded
}
outcome := &GameOutcome{
Opponent: opponent,
Fixture: fixture,
}
// Handle forfeits
if result.IsForfeit {
outcome.IsForfeit = true
outcome.Type = OutcomeForfeit
if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeMutual {
outcome.Type = OutcomeOTLoss // mutual forfeit counts as OT loss for both
} else if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeOutright {
thisSide := "away"
if isHome {
thisSide = "home"
}
if result.ForfeitTeam != nil && *result.ForfeitTeam == thisSide {
outcome.Type = OutcomeLoss // this team forfeited
} else {
outcome.Type = OutcomeWin // opponent forfeited
}
}
return outcome
}
// Normal match - build score string from this team's perspective
if isHome {
outcome.Score = fmt.Sprintf("%d-%d", result.HomeScore, result.AwayScore)
} else {
outcome.Score = fmt.Sprintf("%d-%d", result.AwayScore, result.HomeScore)
}
won := (isHome && result.Winner == "home") || (!isHome && result.Winner == "away")
lost := (isHome && result.Winner == "away") || (!isHome && result.Winner == "home")
isOT := strings.EqualFold(result.EndReason, "Overtime")
switch {
case won && isOT:
outcome.Type = OutcomeOTWin
case won:
outcome.Type = OutcomeWin
case lost && isOT:
outcome.Type = OutcomeOTLoss
case lost:
outcome.Type = OutcomeLoss
default:
outcome.Type = OutcomeDraw
}
return outcome
}
// GetTeamsForSeasonLeague returns all teams participating in a given season+league.
func GetTeamsForSeasonLeague(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Team, error) {
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 = ?", seasonID, leagueID).
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return teams, nil
}
// ComputeMatchPreview fetches all data needed for the match preview tab:
// team standings, positions, and recent game outcomes for both teams.
func ComputeMatchPreview(
ctx context.Context,
tx bun.Tx,
fixture *Fixture,
) (*MatchPreviewData, error) {
if fixture == nil {
return nil, errors.New("fixture cannot be nil")
}
// Get all teams in this season+league
allTeams, err := GetTeamsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return nil, errors.Wrap(err, "GetTeamsForSeasonLeague")
}
// Get all allocated fixtures for the season+league
allFixtures, err := GetAllocatedFixtures(ctx, tx, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return nil, errors.Wrap(err, "GetAllocatedFixtures")
}
// Get finalized results
allFixtureIDs := make([]int, len(allFixtures))
for i, f := range allFixtures {
allFixtureIDs[i] = f.ID
}
allResultMap, err := GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs)
if err != nil {
return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures")
}
// Get accepted schedules for ordering recent games
allScheduleMap, err := GetAcceptedSchedulesForFixtures(ctx, tx, allFixtureIDs)
if err != nil {
return nil, errors.Wrap(err, "GetAcceptedSchedulesForFixtures")
}
// Compute leaderboard
leaderboard := ComputeLeaderboard(allTeams, allFixtures, allResultMap)
// Extract positions and records for both teams
preview := &MatchPreviewData{
TotalTeams: len(leaderboard),
}
for _, entry := range leaderboard {
if entry.Team.ID == fixture.HomeTeamID {
preview.HomePosition = entry.Position
preview.HomeRecord = entry.Record
}
if entry.Team.ID == fixture.AwayTeamID {
preview.AwayPosition = entry.Position
preview.AwayRecord = entry.Record
}
}
if preview.HomeRecord == nil {
preview.HomeRecord = &TeamRecord{}
}
if preview.AwayRecord == nil {
preview.AwayRecord = &TeamRecord{}
}
// Compute recent games (last 5) for each team
preview.HomeRecentGames = ComputeRecentGames(
fixture.HomeTeamID, allFixtures, allResultMap, allScheduleMap, 5,
)
preview.AwayRecentGames = ComputeRecentGames(
fixture.AwayTeamID, allFixtures, allResultMap, allScheduleMap, 5,
)
return preview, nil
}

View File

@@ -99,6 +99,243 @@ func UpdatePlayerSlapID(ctx context.Context, tx bun.Tx, playerID int, slapID uin
return nil return nil
} }
// PlayerAllTimeStats holds aggregated all-time stats for a single player
type PlayerAllTimeStats struct {
GamesPlayed int `bun:"games_played"`
PeriodsPlayed int `bun:"total_periods_played"`
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"`
}
// GetPlayerAllTimeStats returns aggregated all-time stats for a player
// across all finalized fixture results (period 3 totals).
func GetPlayerAllTimeStats(ctx context.Context, tx bun.Tx, playerID int) (*PlayerAllTimeStats, error) {
if playerID == 0 {
return nil, errors.New("playerID not provided")
}
stats := new(PlayerAllTimeStats)
err := tx.NewRaw(`
SELECT
COUNT(DISTINCT frps.fixture_result_id) AS games_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,
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
WHERE fr.finalized = true
AND frps.player_id = ?
AND frps.period_num = 3
`, playerID).Scan(ctx, stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// GetPlayerStatsBySeason returns aggregated stats for a player filtered by season.
func GetPlayerStatsBySeason(ctx context.Context, tx bun.Tx, playerID, seasonID int) (*PlayerAllTimeStats, error) {
if playerID == 0 {
return nil, errors.New("playerID not provided")
}
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
stats := new(PlayerAllTimeStats)
err := tx.NewRaw(`
SELECT
COUNT(DISTINCT frps.fixture_result_id) AS games_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,
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
JOIN fixtures f ON f.id = fr.fixture_id
WHERE fr.finalized = true
AND frps.player_id = ?
AND frps.period_num = 3
AND f.season_id = ?
`, playerID, seasonID).Scan(ctx, stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// GetPlayerStatsByTeam returns aggregated stats for a player filtered by team.
func GetPlayerStatsByTeam(ctx context.Context, tx bun.Tx, playerID, teamID int) (*PlayerAllTimeStats, error) {
if playerID == 0 {
return nil, errors.New("playerID not provided")
}
if teamID == 0 {
return nil, errors.New("teamID not provided")
}
stats := new(PlayerAllTimeStats)
err := tx.NewRaw(`
SELECT
COUNT(DISTINCT frps.fixture_result_id) AS games_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,
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
WHERE fr.finalized = true
AND frps.player_id = ?
AND frps.period_num = 3
AND frps.team_id = ?
`, playerID, teamID).Scan(ctx, stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// PlayerTeamInfo holds a team the player has played on and how many seasons
type PlayerTeamInfo struct {
Team *Team
SeasonsCount int
}
// GetPlayerTeams returns all teams the player has been rostered on,
// with a count of distinct seasons per team.
func GetPlayerTeams(ctx context.Context, tx bun.Tx, playerID int) ([]*PlayerTeamInfo, error) {
if playerID == 0 {
return nil, errors.New("playerID not provided")
}
type teamRow struct {
TeamID int `bun:"team_id"`
SeasonsCount int `bun:"seasons_count"`
Name string `bun:"name"`
ShortName string `bun:"short_name"`
AltShortName string `bun:"alt_short_name"`
Color string `bun:"color"`
}
var rows []teamRow
err := tx.NewRaw(`
SELECT
t.id AS team_id,
t.name AS name,
t.short_name AS short_name,
t.alt_short_name AS alt_short_name,
t.color AS color,
COUNT(DISTINCT tr.season_id) AS seasons_count
FROM team_rosters tr
JOIN teams t ON t.id = tr.team_id
WHERE tr.player_id = ?
GROUP BY t.id, t.name, t.short_name, t.alt_short_name, t.color
ORDER BY seasons_count DESC
`, playerID).Scan(ctx, &rows)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
var results []*PlayerTeamInfo
for _, row := range rows {
results = append(results, &PlayerTeamInfo{
Team: &Team{
ID: row.TeamID,
Name: row.Name,
ShortName: row.ShortName,
AltShortName: row.AltShortName,
Color: row.Color,
},
SeasonsCount: row.SeasonsCount,
})
}
return results, nil
}
// PlayerSeasonInfo holds info about a player's participation in a specific season
type PlayerSeasonInfo struct {
Season *Season
League *League
Team *Team
IsManager bool
}
// GetPlayerSeasons returns all season/league/team combos the player has been rostered in.
func GetPlayerSeasons(ctx context.Context, tx bun.Tx, playerID int) ([]*PlayerSeasonInfo, error) {
if playerID == 0 {
return nil, errors.New("playerID not provided")
}
var rosters []*TeamRoster
err := tx.NewSelect().
Model(&rosters).
Where("tr.player_id = ?", playerID).
Relation("Season").
Relation("League").
Relation("Team").
Order("season.start_date DESC").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
var results []*PlayerSeasonInfo
for _, r := range rosters {
results = append(results, &PlayerSeasonInfo{
Season: r.Season,
League: r.League,
Team: r.Team,
IsManager: r.IsManager,
})
}
return results, nil
}
// GetPlayerSeasonsList returns distinct seasons the player has participated in (for filter dropdowns).
func GetPlayerSeasonsList(ctx context.Context, tx bun.Tx, playerID int) ([]*Season, error) {
if playerID == 0 {
return nil, errors.New("playerID not provided")
}
var seasons []*Season
err := tx.NewSelect().
Model(&seasons).
Join("JOIN team_rosters tr ON tr.season_id = s.id").
Where("tr.player_id = ?", playerID).
GroupExpr("s.id").
Order("s.start_date DESC").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return seasons, nil
}
// GetPlayerTeamsList returns distinct teams the player has played on (for filter dropdowns).
func GetPlayerTeamsList(ctx context.Context, tx bun.Tx, playerID int) ([]*Team, error) {
if playerID == 0 {
return nil, errors.New("playerID not provided")
}
var teams []*Team
err := tx.NewSelect().
Model(&teams).
Join("JOIN team_rosters tr ON tr.team_id = t.id").
Where("tr.player_id = ?", playerID).
GroupExpr("t.id").
Order("t.name ASC").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return teams, nil
}
func GetPlayersNotOnTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Player, error) { func GetPlayersNotOnTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Player, error) {
players, err := GetList[Player](tx).Relation("User"). players, err := GetList[Player](tx).Relation("User").
Join("LEFT JOIN team_rosters tr ON tr.player_id = p.id"). Join("LEFT JOIN team_rosters tr ON tr.player_id = p.id").

View File

@@ -2,6 +2,7 @@ package db
import ( import (
"context" "context"
"sort"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@@ -72,3 +73,149 @@ func (t *Team) InSeason(seasonID int) bool {
} }
return false 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
}

View File

@@ -35,6 +35,8 @@
--text-3xl--line-height: calc(2.25 / 1.875); --text-3xl--line-height: calc(2.25 / 1.875);
--text-4xl: 2.25rem; --text-4xl: 2.25rem;
--text-4xl--line-height: calc(2.5 / 2.25); --text-4xl--line-height: calc(2.5 / 2.25);
--text-5xl: 3rem;
--text-5xl--line-height: 1;
--text-6xl: 3.75rem; --text-6xl: 3.75rem;
--text-6xl--line-height: 1; --text-6xl--line-height: 1;
--text-9xl: 8rem; --text-9xl: 8rem;
@@ -47,6 +49,7 @@
--tracking-tight: -0.025em; --tracking-tight: -0.025em;
--tracking-wider: 0.05em; --tracking-wider: 0.05em;
--leading-relaxed: 1.625; --leading-relaxed: 1.625;
--radius-md: 0.375rem;
--radius-lg: 0.5rem; --radius-lg: 0.5rem;
--radius-xl: 0.75rem; --radius-xl: 0.75rem;
--ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-in: cubic-bezier(0.4, 0, 1, 1);
@@ -450,6 +453,9 @@
.h-3 { .h-3 {
height: calc(var(--spacing) * 3); height: calc(var(--spacing) * 3);
} }
.h-3\.5 {
height: calc(var(--spacing) * 3.5);
}
.h-4 { .h-4 {
height: calc(var(--spacing) * 4); height: calc(var(--spacing) * 4);
} }
@@ -459,9 +465,15 @@
.h-6 { .h-6 {
height: calc(var(--spacing) * 6); height: calc(var(--spacing) * 6);
} }
.h-9 {
height: calc(var(--spacing) * 9);
}
.h-12 { .h-12 {
height: calc(var(--spacing) * 12); height: calc(var(--spacing) * 12);
} }
.h-14 {
height: calc(var(--spacing) * 14);
}
.h-16 { .h-16 {
height: calc(var(--spacing) * 16); height: calc(var(--spacing) * 16);
} }
@@ -510,6 +522,9 @@
.w-3 { .w-3 {
width: calc(var(--spacing) * 3); width: calc(var(--spacing) * 3);
} }
.w-3\.5 {
width: calc(var(--spacing) * 3.5);
}
.w-4 { .w-4 {
width: calc(var(--spacing) * 4); width: calc(var(--spacing) * 4);
} }
@@ -519,18 +534,30 @@
.w-6 { .w-6 {
width: calc(var(--spacing) * 6); width: calc(var(--spacing) * 6);
} }
.w-8 {
width: calc(var(--spacing) * 8);
}
.w-9 {
width: calc(var(--spacing) * 9);
}
.w-10 { .w-10 {
width: calc(var(--spacing) * 10); width: calc(var(--spacing) * 10);
} }
.w-12 { .w-12 {
width: calc(var(--spacing) * 12); width: calc(var(--spacing) * 12);
} }
.w-14 {
width: calc(var(--spacing) * 14);
}
.w-20 { .w-20 {
width: calc(var(--spacing) * 20); width: calc(var(--spacing) * 20);
} }
.w-26 { .w-26 {
width: calc(var(--spacing) * 26); width: calc(var(--spacing) * 26);
} }
.w-28 {
width: calc(var(--spacing) * 28);
}
.w-48 { .w-48 {
width: calc(var(--spacing) * 48); width: calc(var(--spacing) * 48);
} }
@@ -636,6 +663,9 @@
.animate-spin { .animate-spin {
animation: var(--animate-spin); animation: var(--animate-spin);
} }
.cursor-default {
cursor: default;
}
.cursor-grab { .cursor-grab {
cursor: grab; cursor: grab;
} }
@@ -672,6 +702,9 @@
.grid-cols-3 { .grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.grid-cols-7 { .grid-cols-7 {
grid-template-columns: repeat(7, minmax(0, 1fr)); grid-template-columns: repeat(7, minmax(0, 1fr));
} }
@@ -702,6 +735,9 @@
.justify-end { .justify-end {
justify-content: flex-end; justify-content: flex-end;
} }
.gap-0\.5 {
gap: calc(var(--spacing) * 0.5);
}
.gap-1 { .gap-1 {
gap: calc(var(--spacing) * 1); gap: calc(var(--spacing) * 1);
} }
@@ -723,6 +759,13 @@
.gap-8 { .gap-8 {
gap: calc(var(--spacing) * 8); gap: calc(var(--spacing) * 8);
} }
.space-y-0 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-0\.5 { .space-y-0\.5 {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-space-y-reverse: 0; --tw-space-y-reverse: 0;
@@ -737,6 +780,13 @@
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
} }
} }
.space-y-1\.5 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-2 { .space-y-2 {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-space-y-reverse: 0; --tw-space-y-reverse: 0;
@@ -765,6 +815,13 @@
margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
} }
} }
.space-y-8 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)));
}
}
.gap-x-2 { .gap-x-2 {
column-gap: calc(var(--spacing) * 2); column-gap: calc(var(--spacing) * 2);
} }
@@ -854,6 +911,9 @@
.rounded-lg { .rounded-lg {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }
.rounded-md {
border-radius: var(--radius-md);
}
.rounded-xl { .rounded-xl {
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
} }
@@ -999,6 +1059,12 @@
.bg-green { .bg-green {
background-color: var(--green); background-color: var(--green);
} }
.bg-green\/10 {
background-color: var(--green);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--green) 10%, transparent);
}
}
.bg-green\/20 { .bg-green\/20 {
background-color: var(--green); background-color: var(--green);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -1017,6 +1083,18 @@
.bg-mauve { .bg-mauve {
background-color: var(--mauve); background-color: var(--mauve);
} }
.bg-overlay0\/10 {
background-color: var(--overlay0);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--overlay0) 10%, transparent);
}
}
.bg-overlay0\/20 {
background-color: var(--overlay0);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--overlay0) 20%, transparent);
}
}
.bg-peach { .bg-peach {
background-color: var(--peach); background-color: var(--peach);
} }
@@ -1026,6 +1104,12 @@
background-color: color-mix(in oklab, var(--peach) 5%, transparent); background-color: color-mix(in oklab, var(--peach) 5%, transparent);
} }
} }
.bg-peach\/10 {
background-color: var(--peach);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--peach) 10%, transparent);
}
}
.bg-peach\/20 { .bg-peach\/20 {
background-color: var(--peach); background-color: var(--peach);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -1047,12 +1131,24 @@
background-color: color-mix(in oklab, var(--red) 10%, transparent); background-color: color-mix(in oklab, var(--red) 10%, transparent);
} }
} }
.bg-red\/15 {
background-color: var(--red);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--red) 15%, transparent);
}
}
.bg-red\/20 { .bg-red\/20 {
background-color: var(--red); background-color: var(--red);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--red) 20%, transparent); background-color: color-mix(in oklab, var(--red) 20%, transparent);
} }
} }
.bg-red\/30 {
background-color: var(--red);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--red) 30%, transparent);
}
}
.bg-sapphire { .bg-sapphire {
background-color: var(--sapphire); background-color: var(--sapphire);
} }
@@ -1110,6 +1206,9 @@
.p-8 { .p-8 {
padding: calc(var(--spacing) * 8); padding: calc(var(--spacing) * 8);
} }
.px-1 {
padding-inline: calc(var(--spacing) * 1);
}
.px-1\.5 { .px-1\.5 {
padding-inline: calc(var(--spacing) * 1.5); padding-inline: calc(var(--spacing) * 1.5);
} }
@@ -1143,12 +1242,18 @@
.py-2 { .py-2 {
padding-block: calc(var(--spacing) * 2); padding-block: calc(var(--spacing) * 2);
} }
.py-2\.5 {
padding-block: calc(var(--spacing) * 2.5);
}
.py-3 { .py-3 {
padding-block: calc(var(--spacing) * 3); padding-block: calc(var(--spacing) * 3);
} }
.py-4 { .py-4 {
padding-block: calc(var(--spacing) * 4); padding-block: calc(var(--spacing) * 4);
} }
.py-5 {
padding-block: calc(var(--spacing) * 5);
}
.py-6 { .py-6 {
padding-block: calc(var(--spacing) * 6); padding-block: calc(var(--spacing) * 6);
} }
@@ -1173,6 +1278,9 @@
.pr-2 { .pr-2 {
padding-right: calc(var(--spacing) * 2); padding-right: calc(var(--spacing) * 2);
} }
.pr-4 {
padding-right: calc(var(--spacing) * 4);
}
.pr-10 { .pr-10 {
padding-right: calc(var(--spacing) * 10); padding-right: calc(var(--spacing) * 10);
} }
@@ -1188,6 +1296,9 @@
.pl-3 { .pl-3 {
padding-left: calc(var(--spacing) * 3); padding-left: calc(var(--spacing) * 3);
} }
.pl-4 {
padding-left: calc(var(--spacing) * 4);
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
@@ -1212,6 +1323,10 @@
font-size: var(--text-4xl); font-size: var(--text-4xl);
line-height: var(--tw-leading, var(--text-4xl--line-height)); line-height: var(--tw-leading, var(--text-4xl--line-height));
} }
.text-5xl {
font-size: var(--text-5xl);
line-height: var(--tw-leading, var(--text-5xl--line-height));
}
.text-9xl { .text-9xl {
font-size: var(--text-9xl); font-size: var(--text-9xl);
line-height: var(--tw-leading, var(--text-9xl--line-height)); line-height: var(--tw-leading, var(--text-9xl--line-height));
@@ -1299,6 +1414,9 @@
.text-mantle { .text-mantle {
color: var(--mantle); color: var(--mantle);
} }
.text-mauve {
color: var(--mauve);
}
.text-overlay0 { .text-overlay0 {
color: var(--overlay0); color: var(--overlay0);
} }
@@ -1320,6 +1438,9 @@
color: color-mix(in oklab, var(--red) 80%, transparent); color: color-mix(in oklab, var(--red) 80%, transparent);
} }
} }
.text-sky {
color: var(--sky);
}
.text-subtext0 { .text-subtext0 {
color: var(--subtext0); color: var(--subtext0);
} }
@@ -1365,6 +1486,9 @@
.italic { .italic {
font-style: italic; font-style: italic;
} }
.underline {
text-decoration-line: underline;
}
.placeholder-subtext0 { .placeholder-subtext0 {
&::placeholder { &::placeholder {
color: var(--subtext0); color: var(--subtext0);
@@ -1519,6 +1643,12 @@
transition-duration: var(--tw-duration, var(--default-transition-duration)); transition-duration: var(--tw-duration, var(--default-transition-duration));
} }
} }
.last\:border-b-0 {
&:last-child {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 0px;
}
}
.hover\:-translate-y-0\.5 { .hover\:-translate-y-0\.5 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -2001,6 +2131,11 @@
width: calc(var(--spacing) * 10); width: calc(var(--spacing) * 10);
} }
} }
.sm\:w-36 {
@media (width >= 40rem) {
width: calc(var(--spacing) * 36);
}
}
.sm\:w-auto { .sm\:w-auto {
@media (width >= 40rem) { @media (width >= 40rem) {
width: auto; width: auto;
@@ -2073,6 +2208,16 @@
gap: calc(var(--spacing) * 2); gap: calc(var(--spacing) * 2);
} }
} }
.sm\:gap-8 {
@media (width >= 40rem) {
gap: calc(var(--spacing) * 8);
}
}
.sm\:gap-10 {
@media (width >= 40rem) {
gap: calc(var(--spacing) * 10);
}
}
.sm\:p-6 { .sm\:p-6 {
@media (width >= 40rem) { @media (width >= 40rem) {
padding: calc(var(--spacing) * 6); padding: calc(var(--spacing) * 6);
@@ -2103,12 +2248,30 @@
text-align: left; text-align: left;
} }
} }
.sm\:text-2xl {
@media (width >= 40rem) {
font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height));
}
}
.sm\:text-4xl { .sm\:text-4xl {
@media (width >= 40rem) { @media (width >= 40rem) {
font-size: var(--text-4xl); font-size: var(--text-4xl);
line-height: var(--tw-leading, var(--text-4xl--line-height)); line-height: var(--tw-leading, var(--text-4xl--line-height));
} }
} }
.sm\:text-6xl {
@media (width >= 40rem) {
font-size: var(--text-6xl);
line-height: var(--tw-leading, var(--text-6xl--line-height));
}
}
.sm\:text-xl {
@media (width >= 40rem) {
font-size: var(--text-xl);
line-height: var(--tw-leading, var(--text-xl--line-height));
}
}
.md\:col-span-2 { .md\:col-span-2 {
@media (width >= 48rem) { @media (width >= 48rem) {
grid-column: span 2 / span 2; grid-column: span 2 / span 2;
@@ -2174,9 +2337,9 @@
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
} }
.lg\:grid-cols-6 { .lg\:flex-row {
@media (width >= 64rem) { @media (width >= 64rem) {
grid-template-columns: repeat(6, minmax(0, 1fr)); flex-direction: row;
} }
} }
.lg\:items-end { .lg\:items-end {
@@ -2184,6 +2347,11 @@
align-items: flex-end; align-items: flex-end;
} }
} }
.lg\:items-start {
@media (width >= 64rem) {
align-items: flex-start;
}
}
.lg\:justify-between { .lg\:justify-between {
@media (width >= 64rem) { @media (width >= 64rem) {
justify-content: space-between; justify-content: space-between;

View File

@@ -0,0 +1,36 @@
function sortableTable(initField, initDir) {
return {
sortField: initField || "score",
sortDir: initDir || "desc",
sort(field) {
if (this.sortField === field) {
this.sortDir = this.sortDir === "asc" ? "desc" : "asc";
} else {
this.sortField = field;
this.sortDir = "desc";
}
this.reorder();
},
reorder() {
const tbody = this.$refs.tbody;
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll("tr"));
const field = this.sortField;
const dir = this.sortDir === "asc" ? 1 : -1;
rows.sort((a, b) => {
const aVal = parseFloat(a.dataset[field]) || 0;
const bVal = parseFloat(b.dataset[field]) || 0;
if (aVal !== bVal) return (aVal - bVal) * dir;
// Tiebreak: alphabetical by player name
const aName = (a.dataset.name || "").toLowerCase();
const bName = (b.dataset.name || "").toLowerCase();
return aName < bName ? -1 : aName > bName ? 1 : 0;
});
rows.forEach((row) => tbody.appendChild(row));
},
};
}

View File

@@ -47,6 +47,7 @@ func FixtureDetailPage(
var rosters map[string][]*db.PlayerWithPlayStatus var rosters map[string][]*db.PlayerWithPlayStatus
var nominatedFreeAgents []*db.FixtureFreeAgent var nominatedFreeAgents []*db.FixtureFreeAgent
var availableFreeAgents []*db.SeasonLeagueFreeAgent var availableFreeAgents []*db.SeasonLeagueFreeAgent
var previewData *db.MatchPreviewData
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
@@ -94,6 +95,15 @@ func FixtureDetailPage(
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague") return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
} }
} }
// Fetch match preview data for preview and analysis tabs
if activeTab == "preview" || activeTab == "analysis" {
previewData, err = db.ComputeMatchPreview(ctx, tx, fixture)
if err != nil {
return false, errors.Wrap(err, "db.ComputeMatchPreview")
}
}
return true, nil return true, nil
}); !ok { }); !ok {
return return
@@ -102,6 +112,7 @@ func FixtureDetailPage(
renderSafely(seasonsview.FixtureDetailPage( renderSafely(seasonsview.FixtureDetailPage(
fixture, currentSchedule, history, canSchedule, userTeamID, fixture, currentSchedule, history, canSchedule, userTeamID,
result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents, result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents,
previewData,
), s, r, w) ), s, r, w)
}) })
} }

View File

@@ -0,0 +1,104 @@
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/notify"
"git.haelnorr.com/h/oslstats/internal/throw"
playersview "git.haelnorr.com/h/oslstats/internal/view/playersview"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// LinkPlayerSlapID handles the HTMX POST request to link a player's Slapshot ID
// via their Discord Steam connection. Only the player's owner can trigger this.
func LinkPlayerSlapID(
s *hws.Server,
conn *db.DB,
slapAPI *slapshotapi.SlapAPI,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
playerIDStr := r.PathValue("player_id")
playerID, err := strconv.Atoi(playerIDStr)
if err != nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
var player *db.Player
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
user := db.CurrentUser(ctx)
if user == nil {
throw.Unauthorized(s, w, r, "You must be logged in", errors.New("user not authenticated"))
return false, nil
}
var err error
player, err = db.GetPlayer(ctx, tx, playerID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetPlayer")
}
// Verify the current user owns this player
if player.UserID == nil || *player.UserID != user.ID {
throw.ForbiddenSecurity(s, w, r, "You can only link your own player", errors.New("user does not own player"))
return false, nil
}
// Player already has a SlapID
if player.SlapID != nil {
notify.Info(s, w, r, "Already Linked", "Your Slapshot ID is already linked", nil)
return false, nil
}
// Get the user's discord token to look up steam connection
discordToken, err := user.GetDiscordToken(ctx, tx)
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Link Failed", "Discord token not found. Please log out and log back in.", nil)
return false, nil
}
return false, errors.Wrap(err, "user.GetDiscordToken")
}
audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user)
err = ConnectSlapID(ctx, tx, user, discordToken.Convert(), slapAPI, audit)
if err != nil {
return false, errors.Wrap(err, "ConnectSlapID")
}
// Re-fetch the player to check if SlapID was set
player, err = db.GetPlayer(ctx, tx, playerID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayer")
}
if player.SlapID == nil {
// ConnectSlapID returned nil (silent failure) - no steam or no slapID
notify.Warn(s, w, r, "Link Failed",
"Could not find your Slapshot ID. Make sure your Steam account is connected to Discord and you have played Slapshot: Rebound.",
nil)
} else {
notify.Success(s, w, r, "Success", "Your Slapshot ID has been linked!", nil)
}
return true, nil
}); !ok {
return
}
// Re-render the slap ID section with updated state
renderSafely(playersview.SlapIDSection(player, true), s, r, w)
})
}

View File

@@ -0,0 +1,93 @@
package handlers
import (
"context"
"net/http"
"strconv"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
playersview "git.haelnorr.com/h/oslstats/internal/view/playersview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// PlayerStatsFilter handles HTMX POST requests to filter player stats
// by season or team. Only one filter can be active at a time.
// Query params: filter=season|team, filter_id=<id>
func PlayerStatsFilter(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
player, _, ok := resolvePlayerAndOwner(s, conn, w, r)
if !ok {
return
}
filterType := r.URL.Query().Get("filter")
filterIDStr := r.URL.Query().Get("filter_id")
var stats *db.PlayerAllTimeStats
var seasons []*db.Season
var teams []*db.Team
var activeFilter string
var activeFilterID int
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
// Load filter dropdown data
seasons, err = db.GetPlayerSeasonsList(ctx, tx, player.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayerSeasonsList")
}
teams, err = db.GetPlayerTeamsList(ctx, tx, player.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayerTeamsList")
}
// Apply filter
filterID, _ := strconv.Atoi(filterIDStr)
switch filterType {
case "season":
if filterID > 0 {
stats, err = db.GetPlayerStatsBySeason(ctx, tx, player.ID, filterID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayerStatsBySeason")
}
activeFilter = "season"
activeFilterID = filterID
}
case "team":
if filterID > 0 {
stats, err = db.GetPlayerStatsByTeam(ctx, tx, player.ID, filterID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayerStatsByTeam")
}
activeFilter = "team"
activeFilterID = filterID
}
}
// Default to all-time stats if no valid filter
if stats == nil {
stats, err = db.GetPlayerAllTimeStats(ctx, tx, player.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayerAllTimeStats")
}
activeFilter = ""
activeFilterID = 0
}
return true, nil
}); !ok {
return
}
renderSafely(playersview.PlayerStatsTab(
player, stats, seasons, teams,
activeFilter, activeFilterID,
), s, r, w)
})
}

View File

@@ -0,0 +1,186 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strconv"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/throw"
playersview "git.haelnorr.com/h/oslstats/internal/view/playersview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// ProfileRedirect redirects the authenticated user to their own player page.
func ProfileRedirect(
s *hws.Server,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := db.CurrentUser(r.Context())
if user == nil {
throw.Unauthorized(s, w, r, "You must be logged in to view your profile", errors.New("user not authenticated"))
return
}
if user.Player == nil {
throw.InternalServiceError(s, w, r, "Player profile not found", errors.New("user has no linked player"))
return
}
http.Redirect(w, r, fmt.Sprintf("/players/%d", user.Player.ID), http.StatusSeeOther)
})
}
// resolvePlayerAndOwner is a helper that resolves the player from the URL path
// and determines if the current user is the owner of the player.
// Returns false from the outer handler if resolution failed (404 already thrown).
func resolvePlayerAndOwner(
s *hws.Server,
conn *db.DB,
w http.ResponseWriter,
r *http.Request,
) (player *db.Player, isOwner bool, ok bool) {
playerIDStr := r.PathValue("player_id")
playerID, err := strconv.Atoi(playerIDStr)
if err != nil {
throw.NotFound(s, w, r, r.URL.Path)
return nil, false, false
}
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
player, err = db.GetPlayer(ctx, tx, playerID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetPlayer")
}
user := db.CurrentUser(ctx)
if user != nil && player.UserID != nil && *player.UserID == user.ID {
isOwner = true
}
return true, nil
}); !ok {
return nil, false, false
}
// If player has no SlapID and viewer is not the owner, show 404
if player.SlapID == nil && !isOwner {
throw.NotFound(s, w, r, r.URL.Path)
return nil, false, false
}
return player, isOwner, true
}
// PlayerViewStats renders the player profile page with the stats tab active.
// GET renders the full page layout. POST renders just the tab content.
func PlayerViewStats(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r)
if !ok {
return
}
var stats *db.PlayerAllTimeStats
var seasons []*db.Season
var teams []*db.Team
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
stats, err = db.GetPlayerAllTimeStats(ctx, tx, player.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayerAllTimeStats")
}
seasons, err = db.GetPlayerSeasonsList(ctx, tx, player.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayerSeasonsList")
}
teams, err = db.GetPlayerTeamsList(ctx, tx, player.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayerTeamsList")
}
return true, nil
}); !ok {
return
}
if r.Method == "GET" {
renderSafely(playersview.PlayerStatsPage(player, isOwner, stats, seasons, teams), s, r, w)
} else {
renderSafely(playersview.PlayerStatsTab(player, stats, seasons, teams, "", 0), s, r, w)
}
})
}
// PlayerViewTeams renders the teams tab of the player profile page.
func PlayerViewTeams(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r)
if !ok {
return
}
var teamInfos []*db.PlayerTeamInfo
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
teamInfos, err = db.GetPlayerTeams(ctx, tx, player.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayerTeams")
}
return true, nil
}); !ok {
return
}
if r.Method == "GET" {
renderSafely(playersview.PlayerTeamsPage(player, isOwner, teamInfos), s, r, w)
} else {
renderSafely(playersview.PlayerTeamsTab(teamInfos), s, r, w)
}
})
}
// PlayerViewSeasons renders the seasons tab of the player profile page.
func PlayerViewSeasons(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r)
if !ok {
return
}
var seasonInfos []*db.PlayerSeasonInfo
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
seasonInfos, err = db.GetPlayerSeasons(ctx, tx, player.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayerSeasons")
}
return true, nil
}); !ok {
return
}
if r.Method == "GET" {
renderSafely(playersview.PlayerSeasonsPage(player, isOwner, seasonInfos), s, r, w)
} else {
renderSafely(playersview.PlayerSeasonsTab(seasonInfos), s, r, w)
}
})
}

View File

@@ -73,7 +73,7 @@ func Register(
if err != nil { if err != nil {
return false, errors.Wrap(err, "registerUser") return false, errors.Wrap(err, "registerUser")
} }
err = connectSlapID(ctx, tx, user, details.Token, slapAPI, audit) err = ConnectSlapID(ctx, tx, user, details.Token, slapAPI, audit)
if err != nil { if err != nil {
return false, errors.Wrap(err, "connectSlapID") return false, errors.Wrap(err, "connectSlapID")
} }
@@ -123,11 +123,11 @@ func registerUser(ctx context.Context, tx bun.Tx,
return user, nil return user, nil
} }
func connectSlapID(ctx context.Context, tx bun.Tx, user *db.User, // ConnectSlapID attempts to link a player's Slapshot ID via their Discord Steam connection.
// If fails due to no steam connection or no slapID, fails silently and returns nil.
func ConnectSlapID(ctx context.Context, tx bun.Tx, user *db.User,
token *discord.Token, slapAPI *slapshotapi.SlapAPI, audit *db.AuditMeta, token *discord.Token, slapAPI *slapshotapi.SlapAPI, audit *db.AuditMeta,
) error { ) error {
// Attempt to setup their player/slapID from steam connection
// If fails due to no steam connection or no slapID, fail silently and proceed with registration
session, err := discord.NewOAuthSession(token) session, err := discord.NewOAuthSession(token)
if err != nil { if err != nil {
return errors.Wrap(err, "discord.NewOAuthSession") return errors.Wrap(err, "discord.NewOAuthSession")

View File

@@ -22,6 +22,10 @@ func SeasonLeagueStatsPage(
leagueStr := r.PathValue("league_short_name") leagueStr := r.PathValue("league_short_name")
var sl *db.SeasonLeague var sl *db.SeasonLeague
var topGoals []*db.LeagueTopGoalScorer
var topAssists []*db.LeagueTopAssister
var topSaves []*db.LeagueTopSaver
var allStats []*db.LeaguePlayerStats
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
@@ -33,15 +37,36 @@ func SeasonLeagueStatsPage(
} }
return false, errors.Wrap(err, "db.GetSeasonLeague") return false, errors.Wrap(err, "db.GetSeasonLeague")
} }
topGoals, err = db.GetTopGoalScorers(ctx, tx, sl.SeasonID, sl.LeagueID)
if err != nil {
return false, errors.Wrap(err, "db.GetTopGoalScorers")
}
topAssists, err = db.GetTopAssisters(ctx, tx, sl.SeasonID, sl.LeagueID)
if err != nil {
return false, errors.Wrap(err, "db.GetTopAssisters")
}
topSaves, err = db.GetTopSavers(ctx, tx, sl.SeasonID, sl.LeagueID)
if err != nil {
return false, errors.Wrap(err, "db.GetTopSavers")
}
allStats, err = db.GetAllLeaguePlayerStats(ctx, tx, sl.SeasonID, sl.LeagueID)
if err != nil {
return false, errors.Wrap(err, "db.GetAllLeaguePlayerStats")
}
return true, nil return true, nil
}); !ok { }); !ok {
return return
} }
if r.Method == "GET" { if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League), s, r, w) renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League, topGoals, topAssists, topSaves, allStats), s, r, w)
} else { } else {
renderSafely(seasonsview.SeasonLeagueStats(), s, r, w) renderSafely(seasonsview.SeasonLeagueStats(sl.Season, sl.League, topGoals, topAssists, topSaves, allStats), s, r, w)
} }
}) })
} }

View File

@@ -62,7 +62,7 @@ func SeasonLeagueTablePage(
if r.Method == "GET" { if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueTablePage(season, league, leaderboard), s, r, w) renderSafely(seasonsview.SeasonLeagueTablePage(season, league, leaderboard), s, r, w)
} else { } else {
renderSafely(seasonsview.SeasonLeagueTable(leaderboard), s, r, w) renderSafely(seasonsview.SeasonLeagueTable(season, league, leaderboard), s, r, w)
} }
}) })
} }

View File

@@ -35,6 +35,7 @@ func SeasonLeagueTeamDetailPage(
var scheduleMap map[int]*db.FixtureSchedule var scheduleMap map[int]*db.FixtureSchedule
var resultMap map[int]*db.FixtureResult var resultMap map[int]*db.FixtureResult
var playerStats []*db.AggregatedPlayerStats var playerStats []*db.AggregatedPlayerStats
var leaderboard []*db.LeaderboardEntry
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
@@ -72,12 +73,51 @@ func SeasonLeagueTeamDetailPage(
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam") return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
} }
// Get all teams and all fixtures for the league to compute leaderboard
var allTeams []*db.Team
err = tx.NewSelect().
Model(&allTeams).
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
Where("tp.season_id = ? AND tp.league_id = ?", twr.Season.ID, twr.League.ID).
Scan(ctx)
if err != nil {
return false, errors.Wrap(err, "tx.NewSelect allTeams")
}
allFixtures, err := db.GetAllocatedFixtures(ctx, tx, twr.Season.ID, twr.League.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetAllocatedFixtures")
}
allFixtureIDs := make([]int, len(allFixtures))
for i, f := range allFixtures {
allFixtureIDs[i] = f.ID
}
allResultMap, err := db.GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs)
if err != nil {
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures allFixtures")
}
leaderboard = db.ComputeLeaderboard(allTeams, allFixtures, allResultMap)
return true, nil return true, nil
}); !ok { }); !ok {
return return
} }
record := db.ComputeTeamRecord(teamID, fixtures, resultMap) // Find this team's position and record from the leaderboard
renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available, scheduleMap, resultMap, record, playerStats), s, r, w) var position int
var record *db.TeamRecord
for _, entry := range leaderboard {
if entry.Team.ID == teamID {
position = entry.Position
record = entry.Record
break
}
}
if record == nil {
record = &db.TeamRecord{}
}
renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available, scheduleMap, resultMap, record, playerStats, position, len(leaderboard)), s, r, w)
}) })
} }

View File

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

View File

@@ -64,6 +64,11 @@ func addRoutes(
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: auth.LoginReq(handlers.Logout(s, auth, conn, discordAPI)), Handler: auth.LoginReq(handlers.Logout(s, auth, conn, discordAPI)),
}, },
{
Path: "/profile",
Method: hws.MethodGET,
Handler: auth.LoginReq(handlers.ProfileRedirect(s)),
},
} }
seasonRoutes := []hws.Route{ seasonRoutes := []hws.Route{
@@ -295,6 +300,39 @@ func addRoutes(
}, },
} }
playerRoutes := []hws.Route{
{
Path: "/players/{player_id}",
Method: hws.MethodGET,
Handler: handlers.PlayerViewStats(s, conn),
},
{
Path: "/players/{player_id}/stats",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.PlayerViewStats(s, conn),
},
{
Path: "/players/{player_id}/teams",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.PlayerViewTeams(s, conn),
},
{
Path: "/players/{player_id}/seasons",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.PlayerViewSeasons(s, conn),
},
{
Path: "/players/{player_id}/stats/filter",
Method: hws.MethodPOST,
Handler: handlers.PlayerStatsFilter(s, conn),
},
{
Path: "/players/{player_id}/link-slapid",
Method: hws.MethodPOST,
Handler: auth.LoginReq(handlers.LinkPlayerSlapID(s, conn, slapAPI)),
},
}
teamRoutes := []hws.Route{ teamRoutes := []hws.Route{
{ {
Path: "/teams", Path: "/teams",
@@ -316,6 +354,11 @@ func addRoutes(
Method: hws.MethodPOST, Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.TeamsManagePlayers)(handlers.ManageTeamRoster(s, conn)), 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{ htmxRoutes := []hws.Route{
@@ -463,6 +506,7 @@ func addRoutes(
routes = append(routes, leagueRoutes...) routes = append(routes, leagueRoutes...)
routes = append(routes, fixturesRoutes...) routes = append(routes, fixturesRoutes...)
routes = append(routes, teamRoutes...) routes = append(routes, teamRoutes...)
routes = append(routes, playerRoutes...)
// Register the routes with the server // Register the routes with the server
err := s.AddRoutes(routes...) err := s.AddRoutes(routes...)

View File

@@ -0,0 +1,54 @@
package links
import "git.haelnorr.com/h/oslstats/internal/db"
import "fmt"
// PlayerLink renders a player name as a clickable link to their profile page.
// The player's DisplayName() is used as the link text.
templ PlayerLink(player *db.Player) {
<a
href={ templ.SafeURL(fmt.Sprintf("/players/%d", player.ID)) }
class="text-text hover:text-blue transition"
>
{ player.DisplayName() }
</a>
}
// PlayerLinkFromStats renders a player name link using a player ID and name string.
// This is useful when only aggregated stats are available (no full Player object).
templ PlayerLinkFromStats(playerID int, playerName string) {
<a
href={ templ.SafeURL(fmt.Sprintf("/players/%d", playerID)) }
class="text-text hover:text-blue transition"
>
{ playerName }
</a>
}
// TeamLinkInSeason renders a team name as a clickable link to the team's
// season-specific detail page, with an optional color dot prefix.
templ TeamLinkInSeason(team *db.Team, season *db.Season, league *db.League) {
<a
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams/%d", season.ShortName, league.ShortName, team.ID)) }
class="flex items-center gap-2 hover:text-blue transition"
>
if team.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(team.Color) }
></span>
}
<span class="text-sm font-medium">{ team.Name }</span>
</a>
}
// TeamNameLinkInSeason renders just the team name as a clickable link (no color dot).
// Useful where the color dot is already rendered separately or in inline contexts.
templ TeamNameLinkInSeason(team *db.Team, season *db.Season, league *db.League) {
<a
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams/%d", season.ShortName, league.ShortName, team.ID)) }
class="hover:text-blue transition"
>
{ team.Name }
</a>
}

View File

@@ -0,0 +1,97 @@
package playersview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "fmt"
templ PlayerLayout(activeSection string, player *db.Player, isOwner bool) {
@baseview.Layout(player.DisplayName() + " - Player Profile") {
<div class="max-w-screen-2xl mx-auto px-4 py-8">
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<!-- Header -->
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<div class="flex items-center gap-3 mb-2">
<h1 class="text-4xl font-bold text-text">{ player.DisplayName() }</h1>
if isOwner {
<span class="px-2 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium">
Your Profile
</span>
}
</div>
<div class="flex items-center gap-2 flex-wrap">
if player.SlapID != nil {
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) }
</span>
}
</div>
</div>
</div>
</div>
<!-- SlapID Link Prompt (if needed) -->
if player.SlapID == nil && isOwner {
<div class="px-6 pt-6">
@SlapIDSection(player, isOwner)
</div>
}
<!-- Tab Navigation -->
<nav class="bg-surface0 border-b border-surface1" data-tab-nav="player-content">
<ul class="flex flex-wrap">
@playerNavItem("stats", "Stats", activeSection, player)
@playerNavItem("teams", "Teams", activeSection, player)
@playerNavItem("seasons", "Seasons", activeSection, player)
</ul>
</nav>
<!-- Content Area -->
<main class="bg-crust p-6" id="player-content">
{ children... }
</main>
</div>
</div>
<script src="/static/js/tabs.js" defer></script>
}
}
templ playerNavItem(section string, label string, activeSection string, player *db.Player) {
{{
isActive := section == activeSection
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("/players/%d/%s", player.ID, section)
}}
<li class="inline-block">
<a
href={ templ.SafeURL(url) }
hx-post={ url }
hx-target="#player-content"
hx-swap="innerHTML"
hx-push-url={ url }
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
>
{ label }
</a>
</li>
}
// Full page wrappers (for GET requests / direct navigation)
templ PlayerStatsPage(player *db.Player, isOwner bool, stats *db.PlayerAllTimeStats, seasons []*db.Season, teams []*db.Team) {
@PlayerLayout("stats", player, isOwner) {
@PlayerStatsTab(player, stats, seasons, teams, "", 0)
}
}
templ PlayerTeamsPage(player *db.Player, isOwner bool, teamInfos []*db.PlayerTeamInfo) {
@PlayerLayout("teams", player, isOwner) {
@PlayerTeamsTab(teamInfos)
}
}
templ PlayerSeasonsPage(player *db.Player, isOwner bool, seasonInfos []*db.PlayerSeasonInfo) {
@PlayerLayout("seasons", player, isOwner) {
@PlayerSeasonsTab(seasonInfos)
}
}

View File

@@ -0,0 +1,73 @@
package playersview
import "git.haelnorr.com/h/oslstats/internal/db"
import "fmt"
templ PlayerSeasonsTab(seasonInfos []*db.PlayerSeasonInfo) {
if len(seasonInfos) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No season history yet.</p>
<p class="text-subtext1 text-sm mt-2">This player has not participated in any seasons.</p>
</div>
} else {
<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-4 py-3 text-left text-sm font-semibold text-text">Season</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-text">League</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Team</th>
<th class="px-4 py-3 text-center text-sm font-semibold text-text">Role</th>
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for _, info := range seasonInfos {
<tr class="hover:bg-surface1 transition-colors">
<td class="px-4 py-3 text-sm">
<a
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s", info.Season.ShortName)) }
class="text-blue hover:text-blue/80 transition"
>
{ info.Season.Name }
</a>
</td>
<td class="px-4 py-3 text-sm text-subtext0">
{ info.League.Name }
</td>
<td class="px-4 py-3 text-sm">
<a
href={ templ.SafeURL(fmt.Sprintf(
"/seasons/%s/leagues/%s/teams/%d",
info.Season.ShortName, info.League.ShortName, info.Team.ID,
)) }
class="text-blue hover:text-blue/80 transition"
>
<div class="flex items-center gap-2">
if info.Team.Color != "" {
<div
class="w-3 h-3 rounded-full border border-surface1 shrink-0"
style={ "background-color: " + templ.SafeCSS(info.Team.Color) }
></div>
}
<span>{ info.Team.Name }</span>
</div>
</a>
</td>
<td class="px-4 py-3 text-sm text-center">
if info.IsManager {
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
Manager
</span>
} else {
<span class="text-subtext1 text-xs">Player</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}

View File

@@ -0,0 +1,130 @@
package playersview
import "git.haelnorr.com/h/oslstats/internal/db"
import "fmt"
templ PlayerStatsTab(player *db.Player, stats *db.PlayerAllTimeStats, seasons []*db.Season, teams []*db.Team, activeFilter string, activeFilterID int) {
<div class="space-y-6" data-filter-url={ fmt.Sprintf("/players/%d/stats/filter", player.ID) }>
<!-- Filter Controls -->
<div class="flex flex-col sm:flex-row gap-4">
<!-- Season Filter -->
<div class="flex-1">
<label class="block text-xs text-subtext0 uppercase font-medium mb-1">Filter by Season</label>
<select
name="season_id"
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:border-blue focus:outline-none hover:cursor-pointer"
onchange={ handleFilterChange("season") }
>
<option value="">All Seasons</option>
for _, s := range seasons {
<option
value={ fmt.Sprint(s.ID) }
selected?={ activeFilter == "season" && activeFilterID == s.ID }
>
{ s.Name }
</option>
}
</select>
</div>
<!-- Team Filter -->
<div class="flex-1">
<label class="block text-xs text-subtext0 uppercase font-medium mb-1">Filter by Team</label>
<select
name="team_id"
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:border-blue focus:outline-none hover:cursor-pointer"
onchange={ handleFilterChange("team") }
>
<option value="">All Teams</option>
for _, t := range teams {
<option
value={ fmt.Sprint(t.ID) }
selected?={ activeFilter == "team" && activeFilterID == t.ID }
>
{ t.Name }
</option>
}
</select>
</div>
</div>
<!-- Filter Label -->
<div class="text-sm text-subtext0">
if activeFilter == "" {
Showing <span class="text-text font-medium">All-Time</span> stats
} else if activeFilter == "season" {
Showing stats for season:
<span class="text-text font-medium">
{ getSeasonName(seasons, activeFilterID) }
</span>
} else if activeFilter == "team" {
Showing stats for team:
<span class="text-text font-medium">
{ getTeamName(teams, activeFilterID) }
</span>
}
</div>
<!-- Stats Grid -->
@playerStatsGrid(stats)
</div>
}
templ playerStatsGrid(stats *db.PlayerAllTimeStats) {
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
@statCard("Games Played", fmt.Sprint(stats.GamesPlayed), "text-blue")
@statCard("Goals", fmt.Sprint(stats.Goals), "text-green")
@statCard("Assists", fmt.Sprint(stats.Assists), "text-teal")
@statCard("Saves", fmt.Sprint(stats.Saves), "text-yellow")
@statCard("Shots", fmt.Sprint(stats.Shots), "text-peach")
@statCard("Blocks", fmt.Sprint(stats.Blocks), "text-mauve")
@statCard("Passes", fmt.Sprint(stats.Passes), "text-sky")
@statCard("Periods Played", fmt.Sprint(stats.PeriodsPlayed), "text-subtext0")
</div>
}
templ statCard(label string, value string, colorClass string) {
<div class="bg-surface0 border border-surface1 rounded-lg p-4 text-center">
<p class="text-xs text-subtext0 uppercase font-medium mb-1">{ label }</p>
<p class={ "text-2xl font-bold", colorClass }>{ value }</p>
</div>
}
script handleFilterChange(filterType string) {
var container = event.target.closest("[data-filter-url]")
if (!container) return
var baseUrl = container.getAttribute("data-filter-url")
var seasonSelect = container.querySelector("select[name='season_id']")
var teamSelect = container.querySelector("select[name='team_id']")
// Reset the other filter when one is selected
if (filterType === "season" && teamSelect) {
teamSelect.value = ""
} else if (filterType === "team" && seasonSelect) {
seasonSelect.value = ""
}
var value = event.target.value
var url = baseUrl
if (value) {
url += "?filter=" + filterType + "&filter_id=" + value
}
htmx.ajax("POST", url, {target: "#player-content", swap: "innerHTML"})
}
func getSeasonName(seasons []*db.Season, id int) string {
for _, s := range seasons {
if s.ID == id {
return s.Name
}
}
return "Unknown"
}
func getTeamName(teams []*db.Team, id int) string {
for _, t := range teams {
if t.ID == id {
return t.Name
}
}
return "Unknown"
}

View File

@@ -0,0 +1,51 @@
package playersview
import "git.haelnorr.com/h/oslstats/internal/db"
import "fmt"
templ PlayerTeamsTab(teamInfos []*db.PlayerTeamInfo) {
if len(teamInfos) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No team history yet.</p>
<p class="text-subtext1 text-sm mt-2">This player has not been on any teams.</p>
</div>
} else {
<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-4 py-3 text-left text-sm font-semibold text-text">Team</th>
<th class="px-4 py-3 text-right text-sm font-semibold text-text">Seasons Played</th>
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for _, info := range teamInfos {
<tr class="hover:bg-surface1 transition-colors">
<td class="px-4 py-3 text-sm">
<a
href={ templ.SafeURL(fmt.Sprintf("/teams/%d", info.Team.ID)) }
class="text-blue hover:text-blue/80 transition"
>
<div class="flex items-center gap-3">
if info.Team.Color != "" {
<div
class="w-4 h-4 rounded-full border border-surface1 shrink-0"
style={ "background-color: " + templ.SafeCSS(info.Team.Color) }
></div>
}
<span>{ info.Team.Name }</span>
</div>
</a>
</td>
<td class="px-4 py-3 text-sm text-subtext0 text-right">
{ fmt.Sprint(info.SeasonsCount) }
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}

View File

@@ -0,0 +1,52 @@
package playersview
import "git.haelnorr.com/h/oslstats/internal/db"
import "fmt"
templ SlapIDSection(player *db.Player, isOwner bool) {
<div id="slap-id-section">
if player.SlapID == nil && isOwner {
@slapIDLinkPrompt(player)
}
</div>
}
templ slapIDLinkPrompt(player *db.Player) {
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-6">
<div class="flex items-start gap-4">
<svg class="w-6 h-6 text-yellow shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
></path>
</svg>
<div class="flex-1">
<h3 class="text-lg font-semibold text-yellow mb-2">Slapshot ID Not Linked</h3>
<p class="text-subtext0 mb-4">
Your Slapshot ID is not linked. Please link your Steam account to your Discord account, then click the button below to connect your Slapshot ID.
</p>
<p class="text-subtext1 text-sm mb-4">
Need help linking Steam to Discord?
<a
href="https://support.discord.com/hc/en-us/articles/32330173689623-Account-Connections-on-Discord-FAQ#h_01JVZBVNC1HYWX4BTPFN9B4B1V"
target="_blank"
rel="noopener noreferrer"
class="text-blue hover:text-blue/80 underline transition"
>
Follow this guide
</a>
</p>
<button
hx-post={ fmt.Sprintf("/players/%d/link-slapid", player.ID) }
hx-target="#slap-id-section"
hx-swap="outerHTML"
class="px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded font-medium transition hover:cursor-pointer"
>
Link Slapshot ID
</button>
</div>
</div>
</div>
}

View File

@@ -4,6 +4,7 @@ import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/permissions" import "git.haelnorr.com/h/oslstats/internal/permissions"
import "git.haelnorr.com/h/oslstats/internal/contexts" import "git.haelnorr.com/h/oslstats/internal/contexts"
import "git.haelnorr.com/h/oslstats/internal/view/baseview" import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt" import "fmt"
import "sort" import "sort"
import "strings" import "strings"
@@ -19,6 +20,7 @@ templ FixtureDetailPage(
activeTab string, activeTab string,
nominatedFreeAgents []*db.FixtureFreeAgent, nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent, availableFreeAgents []*db.SeasonLeagueFreeAgent,
previewData *db.MatchPreviewData,
) { ) {
{{ {{
permCache := contexts.Permissions(ctx) permCache := contexts.Permissions(ctx)
@@ -32,6 +34,14 @@ templ FixtureDetailPage(
if isFinalized && activeTab == "schedule" { if isFinalized && activeTab == "schedule" {
activeTab = "overview" activeTab = "overview"
} }
// Redirect preview → analysis once finalized
if isFinalized && activeTab == "preview" {
activeTab = "analysis"
}
// Redirect analysis → preview if not finalized
if !isFinalized && activeTab == "analysis" {
activeTab = "preview"
}
}} }}
@baseview.Layout(fmt.Sprintf("%s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) { @baseview.Layout(fmt.Sprintf("%s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
<div class="max-w-screen-lg mx-auto px-4 py-8"> <div class="max-w-screen-lg mx-auto px-4 py-8">
@@ -70,19 +80,26 @@ templ FixtureDetailPage(
</a> </a>
</div> </div>
</div> </div>
<!-- Tab Navigation (hidden when only one tab) --> <!-- Tab Navigation -->
if !isFinalized { <nav class="bg-surface0 border-b border-surface1">
<nav class="bg-surface0 border-b border-surface1"> <ul class="flex flex-wrap">
<ul class="flex flex-wrap"> @fixtureTabItem("overview", "Overview", activeTab, fixture)
@fixtureTabItem("overview", "Overview", activeTab, fixture) if isFinalized {
@fixtureTabItem("analysis", "Match Analysis", activeTab, fixture)
} else {
@fixtureTabItem("preview", "Match Preview", activeTab, fixture)
@fixtureTabItem("schedule", "Schedule", activeTab, fixture) @fixtureTabItem("schedule", "Schedule", activeTab, fixture)
</ul> }
</nav> </ul>
} </nav>
</div> </div>
<!-- Tab Content --> <!-- Tab Content -->
if activeTab == "overview" { if activeTab == "overview" {
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents) @fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents)
} else if activeTab == "preview" && previewData != nil {
@fixtureMatchPreviewTab(fixture, rosters, previewData)
} else if activeTab == "analysis" && result != nil && result.Finalized {
@fixtureMatchAnalysisTab(fixture, result, rosters, previewData)
} else if activeTab == "schedule" { } else if activeTab == "schedule" {
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID) @fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
} }
@@ -147,8 +164,8 @@ templ fixtureOverviewTab(
} }
<!-- Team Rosters --> <!-- Team Rosters -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result) @fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result, fixture.Season, fixture.League)
@fixtureTeamSection(fixture.AwayTeam, rosters["away"], "away", result) @fixtureTeamSection(fixture.AwayTeam, rosters["away"], "away", result, fixture.Season, fixture.League)
</div> </div>
</div> </div>
} }
@@ -603,7 +620,7 @@ templ forfeitModal(fixture *db.Fixture) {
</div> </div>
} }
templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side string, result *db.FixtureResult) { templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side string, result *db.FixtureResult, season *db.Season, league *db.League) {
{{ {{
// Separate playing and bench players // Separate playing and bench players
var playing []*db.PlayerWithPlayStatus var playing []*db.PlayerWithPlayStatus
@@ -640,8 +657,8 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
}} }}
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden"> <div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between"> <div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
<h3 class="text-md font-bold text-text"> <h3 class="text-md font-bold">
{ team.Name } @links.TeamNameLinkInSeason(team, season, league)
</h3> </h3>
if team.Color != "" { if team.Color != "" {
<span <span
@@ -662,6 +679,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
<thead class="bg-surface0 border-b border-surface1"> <thead class="bg-surface0 border-b border-surface1">
<tr> <tr>
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th> <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="Periods Played">PP</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="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="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="Assists">A</th>
@@ -674,9 +692,9 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
<tbody class="divide-y divide-surface1"> <tbody class="divide-y divide-surface1">
for _, p := range playing { for _, p := range playing {
<tr class="hover:bg-surface0 transition-colors"> <tr class="hover:bg-surface0 transition-colors">
<td class="px-3 py-2 text-sm text-text"> <td class="px-3 py-2 text-sm">
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
{ p.Player.DisplayName() } @links.PlayerLink(p.Player)
if p.IsManager { if p.IsManager {
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium"> <span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
&#9733; &#9733;
@@ -690,6 +708,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
</span> </span>
</td> </td>
if p.Stats != nil { if p.Stats != nil {
<td class="px-2 py-2 text-center text-sm text-subtext0">{ intPtrStr(p.Stats.PeriodsPlayed) }</td>
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ intPtrStr(p.Stats.Score) }</td> <td class="px-2 py-2 text-center text-sm font-medium text-text">{ intPtrStr(p.Stats.Score) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Goals) }</td> <td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Goals) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Assists) }</td> <td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Assists) }</td>
@@ -698,7 +717,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Blocks) }</td> <td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Blocks) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Passes) }</td> <td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Passes) }</td>
} else { } else {
<td colspan="7" class="px-2 py-2 text-center text-xs text-subtext1">—</td> <td colspan="8" class="px-2 py-2 text-center text-xs text-subtext1">—</td>
} }
</tr> </tr>
} }
@@ -713,7 +732,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
for _, p := range bench { for _, p := range bench {
<div class="flex items-center gap-2 px-2 py-1.5 rounded"> <div class="flex items-center gap-2 px-2 py-1.5 rounded">
<span class="text-sm text-subtext1"> <span class="text-sm text-subtext1">
{ p.Player.DisplayName() } @links.PlayerLink(p.Player)
</span> </span>
if p.IsManager { if p.IsManager {
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium"> <span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
@@ -735,8 +754,8 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
<div class="space-y-1"> <div class="space-y-1">
for _, p := range playing { for _, p := range playing {
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface0 transition"> <div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface0 transition">
<span class="text-sm text-text"> <span class="text-sm">
{ p.Player.DisplayName() } @links.PlayerLink(p.Player)
</span> </span>
if p.IsManager { if p.IsManager {
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium"> <span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
@@ -758,7 +777,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
for _, p := range bench { for _, p := range bench {
<div class="flex items-center gap-2 px-2 py-1.5 rounded"> <div class="flex items-center gap-2 px-2 py-1.5 rounded">
<span class="text-sm text-subtext1"> <span class="text-sm text-subtext1">
{ p.Player.DisplayName() } @links.PlayerLink(p.Player)
</span> </span>
if p.IsManager { if p.IsManager {
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium"> <span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
@@ -838,7 +857,9 @@ templ fixtureFreeAgentSection(
for _, n := range homeNominated { for _, n := range homeNominated {
<div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition"> <div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<span class="text-sm text-text">{ n.Player.DisplayName() }</span> <span class="text-sm">
@links.PlayerLink(n.Player)
</span>
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium"> <span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FA FA
</span> </span>
@@ -873,7 +894,9 @@ templ fixtureFreeAgentSection(
for _, n := range awayNominated { for _, n := range awayNominated {
<div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition"> <div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<span class="text-sm text-text">{ n.Player.DisplayName() }</span> <span class="text-sm">
@links.PlayerLink(n.Player)
</span>
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium"> <span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FA FA
</span> </span>

View File

@@ -0,0 +1,611 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt"
import "sort"
import "strings"
// teamAggStats holds aggregated stats for a single team in a fixture.
type teamAggStats struct {
Goals int
Assists int
PrimaryAssists int
SecondaryAssists int
Saves int
Shots int
Blocks int
Passes int
Turnovers int
Takeaways int
FaceoffsWon int
FaceoffsLost int
PostHits int
PossessionSec int
PlayersUsed int
}
func aggregateTeamStats(players []*db.PlayerWithPlayStatus) *teamAggStats {
agg := &teamAggStats{}
for _, p := range players {
if !p.Played || p.Stats == nil {
continue
}
agg.PlayersUsed++
if p.Stats.Goals != nil {
agg.Goals += *p.Stats.Goals
}
if p.Stats.Assists != nil {
agg.Assists += *p.Stats.Assists
}
if p.Stats.PrimaryAssists != nil {
agg.PrimaryAssists += *p.Stats.PrimaryAssists
}
if p.Stats.SecondaryAssists != nil {
agg.SecondaryAssists += *p.Stats.SecondaryAssists
}
if p.Stats.Saves != nil {
agg.Saves += *p.Stats.Saves
}
if p.Stats.Shots != nil {
agg.Shots += *p.Stats.Shots
}
if p.Stats.Blocks != nil {
agg.Blocks += *p.Stats.Blocks
}
if p.Stats.Passes != nil {
agg.Passes += *p.Stats.Passes
}
if p.Stats.Turnovers != nil {
agg.Turnovers += *p.Stats.Turnovers
}
if p.Stats.Takeaways != nil {
agg.Takeaways += *p.Stats.Takeaways
}
if p.Stats.FaceoffsWon != nil {
agg.FaceoffsWon += *p.Stats.FaceoffsWon
}
if p.Stats.FaceoffsLost != nil {
agg.FaceoffsLost += *p.Stats.FaceoffsLost
}
if p.Stats.PostHits != nil {
agg.PostHits += *p.Stats.PostHits
}
if p.Stats.PossessionTimeSec != nil {
agg.PossessionSec += *p.Stats.PossessionTimeSec
}
}
return agg
}
func formatPossession(seconds int) string {
m := seconds / 60
s := seconds % 60
return fmt.Sprintf("%d:%02d", m, s)
}
func faceoffPct(won, lost int) string {
total := won + lost
if total == 0 {
return "0%"
}
pct := float64(won) / float64(total) * 100
return fmt.Sprintf("%.0f%%", pct)
}
// fixtureMatchAnalysisTab renders the full Match Analysis tab for completed fixtures.
// Shows score, team stats comparison, match details, and top performers.
templ fixtureMatchAnalysisTab(
fixture *db.Fixture,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
preview *db.MatchPreviewData,
) {
<div class="space-y-6">
<!-- Score Display -->
@analysisScoreHeader(fixture, result)
<!-- Team Stats Comparison -->
@analysisTeamStatsComparison(fixture, rosters)
<!-- Top Performers -->
@analysisTopPerformers(fixture, rosters)
<!-- Standings Context (from preview data) -->
if preview != nil {
@analysisStandingsContext(fixture, preview)
}
</div>
}
// analysisScoreHeader renders the final score in a prominent broadcast-style display.
templ analysisScoreHeader(fixture *db.Fixture, result *db.FixtureResult) {
{{
isOT := strings.EqualFold(result.EndReason, "Overtime")
homeWon := result.Winner == "home"
awayWon := result.Winner == "away"
isForfeit := result.IsForfeit
}}
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
<h2 class="text-lg font-bold text-text">Final Score</h2>
</div>
<div class="p-6">
if isForfeit {
@analysisForfeitDisplay(fixture, result)
} else {
<div class="flex items-center justify-center gap-6 sm:gap-10">
<!-- Home Team -->
<div class="flex flex-col items-center text-center flex-1">
if fixture.HomeTeam.Color != "" {
<div
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
></div>
}
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
</h3>
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", homeWon), templ.KV("text-text", !homeWon) }>
{ fmt.Sprint(result.HomeScore) }
</span>
if homeWon {
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
}
</div>
<!-- Divider -->
<div class="flex flex-col items-center shrink-0">
<span class="text-4xl text-subtext0 font-light"></span>
if isOT {
<span class="mt-1 px-2 py-0.5 bg-peach/20 text-peach rounded text-xs font-bold">OT</span>
}
</div>
<!-- Away Team -->
<div class="flex flex-col items-center text-center flex-1">
if fixture.AwayTeam.Color != "" {
<div
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
></div>
}
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
</h3>
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", awayWon), templ.KV("text-text", !awayWon) }>
{ fmt.Sprint(result.AwayScore) }
</span>
if awayWon {
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
}
</div>
</div>
}
</div>
</div>
}
// analysisForfeitDisplay renders a forfeit result in the analysis header.
templ analysisForfeitDisplay(fixture *db.Fixture, result *db.FixtureResult) {
{{
isMutualForfeit := result.ForfeitType != nil && *result.ForfeitType == "mutual"
isOutrightForfeit := result.ForfeitType != nil && *result.ForfeitType == "outright"
forfeitTeamName := ""
winnerTeamName := ""
if isOutrightForfeit && result.ForfeitTeam != nil {
if *result.ForfeitTeam == "home" {
forfeitTeamName = fixture.HomeTeam.Name
winnerTeamName = fixture.AwayTeam.Name
} else {
forfeitTeamName = fixture.AwayTeam.Name
winnerTeamName = fixture.HomeTeam.Name
}
}
}}
<div class="flex flex-col items-center py-4 space-y-4">
if isMutualForfeit {
<span class="px-4 py-2 bg-peach/20 text-peach rounded-lg text-lg font-bold">MUTUAL FORFEIT</span>
<p class="text-sm text-subtext0">Both teams receive an overtime loss</p>
} else if isOutrightForfeit {
<span class="px-4 py-2 bg-red/20 text-red rounded-lg text-lg font-bold">FORFEIT</span>
<p class="text-sm text-subtext0">
{ forfeitTeamName } forfeited — { winnerTeamName } wins
</p>
}
if result.ForfeitReason != nil && *result.ForfeitReason != "" {
<div class="bg-surface0 border border-surface1 rounded-lg p-3 max-w-md w-full text-center">
<p class="text-xs text-subtext1 font-medium mb-1">Reason</p>
<p class="text-sm text-subtext0">{ *result.ForfeitReason }</p>
</div>
}
</div>
}
// analysisTeamStatsComparison renders aggregated team stats in the broadcast comparison layout.
templ analysisTeamStatsComparison(fixture *db.Fixture, rosters map[string][]*db.PlayerWithPlayStatus) {
{{
homeAgg := aggregateTeamStats(rosters["home"])
awayAgg := aggregateTeamStats(rosters["away"])
}}
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
<h2 class="text-lg font-bold text-text">Team Statistics</h2>
</div>
<div class="p-6">
<!-- Team Name Headers -->
<div class="flex items-center mb-4">
<div class="flex-1 text-right pr-4">
<div class="flex items-center justify-end gap-2">
if fixture.HomeTeam.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
></span>
}
<span class="text-sm font-bold text-text">{ fixture.HomeTeam.ShortName }</span>
</div>
</div>
<div class="w-28 sm:w-36 text-center shrink-0"></div>
<div class="flex-1 text-left pl-4">
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-text">{ fixture.AwayTeam.ShortName }</span>
if fixture.AwayTeam.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
></span>
}
</div>
</div>
</div>
<!-- Stats Rows -->
<div class="space-y-0">
@previewStatRow(
fmt.Sprint(homeAgg.Goals),
"Goals",
fmt.Sprint(awayAgg.Goals),
homeAgg.Goals > awayAgg.Goals,
awayAgg.Goals > homeAgg.Goals,
)
@previewStatRow(
fmt.Sprint(homeAgg.Assists),
"Assists",
fmt.Sprint(awayAgg.Assists),
homeAgg.Assists > awayAgg.Assists,
awayAgg.Assists > homeAgg.Assists,
)
@previewStatRow(
fmt.Sprint(homeAgg.Shots),
"Shots",
fmt.Sprint(awayAgg.Shots),
homeAgg.Shots > awayAgg.Shots,
awayAgg.Shots > homeAgg.Shots,
)
@previewStatRow(
fmt.Sprint(homeAgg.Saves),
"Saves",
fmt.Sprint(awayAgg.Saves),
homeAgg.Saves > awayAgg.Saves,
awayAgg.Saves > homeAgg.Saves,
)
@previewStatRow(
fmt.Sprint(homeAgg.Blocks),
"Blocks",
fmt.Sprint(awayAgg.Blocks),
homeAgg.Blocks > awayAgg.Blocks,
awayAgg.Blocks > homeAgg.Blocks,
)
@previewStatRow(
fmt.Sprint(homeAgg.Passes),
"Passes",
fmt.Sprint(awayAgg.Passes),
homeAgg.Passes > awayAgg.Passes,
awayAgg.Passes > homeAgg.Passes,
)
@previewStatRow(
fmt.Sprint(homeAgg.Takeaways),
"Takeaways",
fmt.Sprint(awayAgg.Takeaways),
homeAgg.Takeaways > awayAgg.Takeaways,
awayAgg.Takeaways > homeAgg.Takeaways,
)
@previewStatRow(
fmt.Sprint(homeAgg.Turnovers),
"Turnovers",
fmt.Sprint(awayAgg.Turnovers),
homeAgg.Turnovers < awayAgg.Turnovers,
awayAgg.Turnovers < homeAgg.Turnovers,
)
<!-- Faceoffs -->
{{
homeFO := homeAgg.FaceoffsWon + homeAgg.FaceoffsLost
awayFO := awayAgg.FaceoffsWon + awayAgg.FaceoffsLost
homeFOStr := fmt.Sprintf("%d/%d", homeAgg.FaceoffsWon, homeFO)
awayFOStr := fmt.Sprintf("%d/%d", awayAgg.FaceoffsWon, awayFO)
}}
@previewStatRow(
homeFOStr,
"Faceoffs Won",
awayFOStr,
homeAgg.FaceoffsWon > awayAgg.FaceoffsWon,
awayAgg.FaceoffsWon > homeAgg.FaceoffsWon,
)
@previewStatRow(
faceoffPct(homeAgg.FaceoffsWon, homeAgg.FaceoffsLost),
"Faceoff %",
faceoffPct(awayAgg.FaceoffsWon, awayAgg.FaceoffsLost),
homeAgg.FaceoffsWon * (awayAgg.FaceoffsWon + awayAgg.FaceoffsLost) > awayAgg.FaceoffsWon * (homeAgg.FaceoffsWon + homeAgg.FaceoffsLost),
awayAgg.FaceoffsWon * (homeAgg.FaceoffsWon + homeAgg.FaceoffsLost) > homeAgg.FaceoffsWon * (awayAgg.FaceoffsWon + awayAgg.FaceoffsLost),
)
@previewStatRow(
fmt.Sprint(homeAgg.PostHits),
"Post Hits",
fmt.Sprint(awayAgg.PostHits),
homeAgg.PostHits > awayAgg.PostHits,
awayAgg.PostHits > homeAgg.PostHits,
)
@previewStatRow(
formatPossession(homeAgg.PossessionSec),
"Possession",
formatPossession(awayAgg.PossessionSec),
homeAgg.PossessionSec > awayAgg.PossessionSec,
awayAgg.PossessionSec > homeAgg.PossessionSec,
)
@previewStatRow(
fmt.Sprint(homeAgg.PlayersUsed),
"Players Used",
fmt.Sprint(awayAgg.PlayersUsed),
false,
false,
)
</div>
</div>
</div>
}
// analysisTopPerformers shows the top players from each team based on score.
templ analysisTopPerformers(fixture *db.Fixture, rosters map[string][]*db.PlayerWithPlayStatus) {
{{
// Collect players who played and have stats, sorted by score descending
type scoredPlayer struct {
Player *db.Player
Stats *db.FixtureResultPlayerStats
IsManager bool
IsFreeAgent bool
}
collectTop := func(players []*db.PlayerWithPlayStatus, limit int) []*scoredPlayer {
var scored []*scoredPlayer
for _, p := range players {
if !p.Played || p.Stats == nil || p.Player == nil {
continue
}
scored = append(scored, &scoredPlayer{
Player: p.Player,
Stats: p.Stats,
IsManager: p.IsManager,
IsFreeAgent: p.IsFreeAgent,
})
}
sort.Slice(scored, func(i, j int) bool {
si, sj := 0, 0
if scored[i].Stats.Score != nil {
si = *scored[i].Stats.Score
}
if scored[j].Stats.Score != nil {
sj = *scored[j].Stats.Score
}
return si > sj
})
if len(scored) > limit {
scored = scored[:limit]
}
return scored
}
homeTop := collectTop(rosters["home"], 3)
awayTop := collectTop(rosters["away"], 3)
}}
if len(homeTop) > 0 || len(awayTop) > 0 {
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
<h2 class="text-lg font-bold text-text">Top Performers</h2>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Home Top Performers -->
<div>
<div class="flex items-center gap-2 mb-3">
if fixture.HomeTeam.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
></span>
}
<h3 class="text-md font-bold text-text">{ fixture.HomeTeam.Name }</h3>
</div>
<div class="space-y-2">
for i, p := range homeTop {
@topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1)
}
</div>
</div>
<!-- Away Top Performers -->
<div>
<div class="flex items-center gap-2 mb-3">
if fixture.AwayTeam.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
></span>
}
<h3 class="text-md font-bold text-text">{ fixture.AwayTeam.Name }</h3>
</div>
<div class="space-y-2">
for i, p := range awayTop {
@topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1)
}
</div>
</div>
</div>
</div>
</div>
}
}
// topPerformerCard renders a single top performer card with key stats.
templ topPerformerCard(player *db.Player, stats *db.FixtureResultPlayerStats, isManager bool, isFreeAgent bool, rank int) {
{{
rankLabels := map[int]string{1: "🥇", 2: "🥈", 3: "🥉"}
rankLabel := rankLabels[rank]
}}
<div class="flex items-center gap-3 px-4 py-3 bg-surface0 border border-surface1 rounded-lg">
<span class="text-lg shrink-0">{ rankLabel }</span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium truncate">
@links.PlayerLink(player)
</span>
if isManager {
<span class="px-1 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium shrink-0">
&#9733;
</span>
}
if isFreeAgent {
<span class="px-1 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium shrink-0">
FA
</span>
}
</div>
<div class="flex items-center gap-3 mt-1 text-xs text-subtext0">
if stats.Score != nil {
<span title="Score"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Score) }</span> SC</span>
}
if stats.Goals != nil {
<span title="Goals"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Goals) }</span> G</span>
}
if stats.Assists != nil {
<span title="Assists"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Assists) }</span> A</span>
}
if stats.Saves != nil {
<span title="Saves"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Saves) }</span> SV</span>
}
if stats.Shots != nil {
<span title="Shots"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Shots) }</span> SH</span>
}
</div>
</div>
</div>
}
// analysisStandingsContext shows how this result fits into the league standings.
templ analysisStandingsContext(fixture *db.Fixture, preview *db.MatchPreviewData) {
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
<h2 class="text-lg font-bold text-text">League Context</h2>
</div>
<div class="p-6">
<!-- Team Name Headers -->
<div class="flex items-center mb-4">
<div class="flex-1 text-right pr-4">
<div class="flex items-center justify-end gap-2">
if fixture.HomeTeam.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
></span>
}
<span class="text-sm font-bold text-text">{ fixture.HomeTeam.ShortName }</span>
</div>
</div>
<div class="w-28 sm:w-36 text-center shrink-0"></div>
<div class="flex-1 text-left pl-4">
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-text">{ fixture.AwayTeam.ShortName }</span>
if fixture.AwayTeam.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
></span>
}
</div>
</div>
</div>
<div class="space-y-0">
{{
homePos := ordinal(preview.HomePosition)
awayPos := ordinal(preview.AwayPosition)
if preview.HomePosition == 0 {
homePos = "N/A"
}
if preview.AwayPosition == 0 {
awayPos = "N/A"
}
}}
@previewStatRow(
homePos,
"Position",
awayPos,
preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition,
preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition,
)
@previewStatRow(
fmt.Sprint(preview.HomeRecord.Points),
"Points",
fmt.Sprint(preview.AwayRecord.Points),
preview.HomeRecord.Points > preview.AwayRecord.Points,
preview.AwayRecord.Points > preview.HomeRecord.Points,
)
@previewStatRow(
fmt.Sprintf("%d-%d-%d-%d",
preview.HomeRecord.Wins,
preview.HomeRecord.OvertimeWins,
preview.HomeRecord.OvertimeLosses,
preview.HomeRecord.Losses,
),
"Record",
fmt.Sprintf("%d-%d-%d-%d",
preview.AwayRecord.Wins,
preview.AwayRecord.OvertimeWins,
preview.AwayRecord.OvertimeLosses,
preview.AwayRecord.Losses,
),
false,
false,
)
{{
homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst
awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst
}}
@previewStatRow(
fmt.Sprintf("%+d", homeDiff),
"Goal Diff",
fmt.Sprintf("%+d", awayDiff),
homeDiff > awayDiff,
awayDiff > homeDiff,
)
<!-- Recent Form -->
if len(preview.HomeRecentGames) > 0 || len(preview.AwayRecentGames) > 0 {
<div class="flex items-center py-3 border-b border-surface1 last:border-b-0">
<div class="flex-1 flex justify-end pr-4">
<div class="flex items-center gap-1">
for _, g := range preview.HomeRecentGames {
@gameOutcomeIcon(g)
}
</div>
</div>
<div class="w-28 sm:w-36 text-center shrink-0">
<span class="text-xs font-semibold text-subtext0 uppercase tracking-wider">Form</span>
</div>
<div class="flex-1 flex pl-4">
<div class="flex items-center gap-1">
for _, g := range preview.AwayRecentGames {
@gameOutcomeIcon(g)
}
</div>
</div>
</div>
}
</div>
</div>
</div>
}

View File

@@ -0,0 +1,435 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt"
import "sort"
// fixtureMatchPreviewTab renders the full Match Preview tab content.
// Shows team standings comparison, recent form, and full rosters side-by-side.
templ fixtureMatchPreviewTab(
fixture *db.Fixture,
rosters map[string][]*db.PlayerWithPlayStatus,
preview *db.MatchPreviewData,
) {
<div class="space-y-6">
<!-- Team Comparison Header -->
@matchPreviewHeader(fixture, preview)
<!-- Form Guide (Last 5 Games) -->
@matchPreviewFormGuide(fixture, preview)
<!-- Team Rosters -->
@matchPreviewRosters(fixture, rosters)
</div>
}
// matchPreviewHeader renders the broadcast-style team comparison with standings.
templ matchPreviewHeader(fixture *db.Fixture, preview *db.MatchPreviewData) {
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
<h2 class="text-lg font-bold text-text">Team Comparison</h2>
</div>
<div class="p-6">
<!-- Team Names and VS -->
<div class="flex items-center justify-center gap-4 sm:gap-8 mb-8">
<!-- Home Team -->
<div class="flex flex-col items-center text-center flex-1">
if fixture.HomeTeam.Color != "" {
<div
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
></div>
}
<h3 class="text-xl sm:text-2xl font-bold text-text">
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
</h3>
<span class="text-subtext0 text-sm font-mono mt-1">{ fixture.HomeTeam.ShortName }</span>
</div>
<!-- VS Divider -->
<div class="flex flex-col items-center shrink-0">
<span class="text-3xl sm:text-4xl font-bold text-subtext0">VS</span>
</div>
<!-- Away Team -->
<div class="flex flex-col items-center text-center flex-1">
if fixture.AwayTeam.Color != "" {
<div
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
></div>
}
<h3 class="text-xl sm:text-2xl font-bold text-text">
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
</h3>
<span class="text-subtext0 text-sm font-mono mt-1">{ fixture.AwayTeam.ShortName }</span>
</div>
</div>
<!-- Stats Comparison Grid -->
{{
homePos := ordinal(preview.HomePosition)
awayPos := ordinal(preview.AwayPosition)
if preview.HomePosition == 0 {
homePos = "N/A"
}
if preview.AwayPosition == 0 {
awayPos = "N/A"
}
}}
<div class="space-y-0">
<!-- Position -->
@previewStatRow(
homePos,
"Position",
awayPos,
preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition,
preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition,
)
<!-- Points -->
@previewStatRow(
fmt.Sprint(preview.HomeRecord.Points),
"Points",
fmt.Sprint(preview.AwayRecord.Points),
preview.HomeRecord.Points > preview.AwayRecord.Points,
preview.AwayRecord.Points > preview.HomeRecord.Points,
)
<!-- Played -->
@previewStatRow(
fmt.Sprint(preview.HomeRecord.Played),
"Played",
fmt.Sprint(preview.AwayRecord.Played),
false,
false,
)
<!-- Wins -->
@previewStatRow(
fmt.Sprint(preview.HomeRecord.Wins),
"Wins",
fmt.Sprint(preview.AwayRecord.Wins),
preview.HomeRecord.Wins > preview.AwayRecord.Wins,
preview.AwayRecord.Wins > preview.HomeRecord.Wins,
)
<!-- OT Wins -->
@previewStatRow(
fmt.Sprint(preview.HomeRecord.OvertimeWins),
"OT Wins",
fmt.Sprint(preview.AwayRecord.OvertimeWins),
preview.HomeRecord.OvertimeWins > preview.AwayRecord.OvertimeWins,
preview.AwayRecord.OvertimeWins > preview.HomeRecord.OvertimeWins,
)
<!-- OT Losses -->
@previewStatRow(
fmt.Sprint(preview.HomeRecord.OvertimeLosses),
"OT Losses",
fmt.Sprint(preview.AwayRecord.OvertimeLosses),
preview.HomeRecord.OvertimeLosses < preview.AwayRecord.OvertimeLosses,
preview.AwayRecord.OvertimeLosses < preview.HomeRecord.OvertimeLosses,
)
<!-- Losses -->
@previewStatRow(
fmt.Sprint(preview.HomeRecord.Losses),
"Losses",
fmt.Sprint(preview.AwayRecord.Losses),
preview.HomeRecord.Losses < preview.AwayRecord.Losses,
preview.AwayRecord.Losses < preview.HomeRecord.Losses,
)
<!-- Goals For -->
@previewStatRow(
fmt.Sprint(preview.HomeRecord.GoalsFor),
"Goals For",
fmt.Sprint(preview.AwayRecord.GoalsFor),
preview.HomeRecord.GoalsFor > preview.AwayRecord.GoalsFor,
preview.AwayRecord.GoalsFor > preview.HomeRecord.GoalsFor,
)
<!-- Goals Against -->
@previewStatRow(
fmt.Sprint(preview.HomeRecord.GoalsAgainst),
"Goals Against",
fmt.Sprint(preview.AwayRecord.GoalsAgainst),
preview.HomeRecord.GoalsAgainst < preview.AwayRecord.GoalsAgainst,
preview.AwayRecord.GoalsAgainst < preview.HomeRecord.GoalsAgainst,
)
<!-- Goal Difference -->
{{
homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst
awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst
homeDiffStr := fmt.Sprintf("%+d", homeDiff)
awayDiffStr := fmt.Sprintf("%+d", awayDiff)
}}
@previewStatRow(
homeDiffStr,
"Goal Diff",
awayDiffStr,
homeDiff > awayDiff,
awayDiff > homeDiff,
)
</div>
</div>
</div>
}
// previewStatRow renders a single comparison stat row in the broadcast-style layout.
// The stat label is centered, with home value on the left and away value on the right.
// homeHighlight/awayHighlight indicate which side has the better value.
templ previewStatRow(homeValue, label, awayValue string, homeHighlight, awayHighlight bool) {
<div class="flex items-center py-2.5 border-b border-surface1 last:border-b-0">
<!-- Home Value -->
<div class="flex-1 text-right pr-4">
<span class={ "text-lg font-bold", templ.KV("text-green", homeHighlight), templ.KV("text-text", !homeHighlight) }>
{ homeValue }
</span>
</div>
<!-- Label -->
<div class="w-28 sm:w-36 text-center shrink-0">
<span class="text-xs font-semibold text-subtext0 uppercase tracking-wider">{ label }</span>
</div>
<!-- Away Value -->
<div class="flex-1 text-left pl-4">
<span class={ "text-lg font-bold", templ.KV("text-green", awayHighlight), templ.KV("text-text", !awayHighlight) }>
{ awayValue }
</span>
</div>
</div>
}
// matchPreviewFormGuide renders the recent form section with last 5 game outcome icons.
templ matchPreviewFormGuide(fixture *db.Fixture, preview *db.MatchPreviewData) {
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
<h2 class="text-lg font-bold text-text">Recent Form</h2>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Home Team Form -->
<div>
<div class="flex items-center gap-2 mb-4">
if fixture.HomeTeam.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
></span>
}
<h3 class="text-md font-bold text-text">{ fixture.HomeTeam.Name }</h3>
</div>
if len(preview.HomeRecentGames) == 0 {
<p class="text-subtext1 text-sm">No recent matches played</p>
} else {
<!-- Outcome Icons: chronological (oldest → newest, left → right) -->
<div class="flex items-center gap-1.5 mb-4">
for _, g := range preview.HomeRecentGames {
@gameOutcomeIcon(g)
}
</div>
<!-- Recent Results List: most recent first -->
<div class="space-y-1.5">
for i := len(preview.HomeRecentGames) - 1; i >= 0; i-- {
@recentGameRow(preview.HomeRecentGames[i])
}
</div>
}
</div>
<!-- Away Team Form -->
<div>
<div class="flex items-center gap-2 mb-4">
if fixture.AwayTeam.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
></span>
}
<h3 class="text-md font-bold text-text">{ fixture.AwayTeam.Name }</h3>
</div>
if len(preview.AwayRecentGames) == 0 {
<p class="text-subtext1 text-sm">No recent matches played</p>
} else {
<!-- Outcome Icons: chronological (oldest → newest, left → right) -->
<div class="flex items-center gap-1.5 mb-4">
for _, g := range preview.AwayRecentGames {
@gameOutcomeIcon(g)
}
</div>
<!-- Recent Results List: most recent first -->
<div class="space-y-1.5">
for i := len(preview.AwayRecentGames) - 1; i >= 0; i-- {
@recentGameRow(preview.AwayRecentGames[i])
}
</div>
}
</div>
</div>
</div>
</div>
}
// outcomeStyle holds the styling info for a game outcome type.
type outcomeStyle struct {
iconBg string // Background class for the icon badge
rowBg string // Background class for the row
text string // Text color class
label string // Short label (W, L, OW, OL, D, F)
fullLabel string // Full label for row display (W, OTW, OTL, L, D, FF)
desc string // Human-readable description (Win, Loss, etc.)
}
func getOutcomeStyle(outcomeType string) outcomeStyle {
switch outcomeType {
case "W":
return outcomeStyle{"bg-green/20", "bg-green/10", "text-green", "W", "W", "Win"}
case "OTW":
return outcomeStyle{"bg-yellow/20", "bg-yellow/10", "text-yellow", "OW", "OTW", "OT Win"}
case "OTL":
return outcomeStyle{"bg-peach/20", "bg-peach/10", "text-peach", "OL", "OTL", "OT Loss"}
case "L":
return outcomeStyle{"bg-red/20", "bg-red/10", "text-red", "L", "L", "Loss"}
case "D":
return outcomeStyle{"bg-overlay0/20", "bg-overlay0/10", "text-overlay0", "D", "D", "Draw"}
case "F":
return outcomeStyle{"bg-red/30", "bg-red/15", "text-red", "F", "FF", "Forfeit"}
default:
return outcomeStyle{"bg-surface1", "bg-surface0", "text-subtext0", "?", "?", "Unknown"}
}
}
// gameOutcomeIcon renders a single game outcome as a colored badge.
templ gameOutcomeIcon(outcome *db.GameOutcome) {
{{
style := getOutcomeStyle(outcome.Type)
tooltip := ""
if outcome.Opponent != nil {
tooltip = fmt.Sprintf("%s vs %s", style.desc, outcome.Opponent.Name)
if outcome.IsForfeit {
tooltip += " (Forfeit)"
} else if outcome.Score != "" {
tooltip += fmt.Sprintf(" (%s)", outcome.Score)
}
}
}}
<span
class={ "inline-flex items-center justify-center w-9 h-9 rounded-md text-xs font-bold cursor-default", style.iconBg, style.text }
title={ tooltip }
>
{ style.label }
</span>
}
// recentGameRow renders a single recent game result as a compact row.
templ recentGameRow(outcome *db.GameOutcome) {
{{
style := getOutcomeStyle(outcome.Type)
opponentName := "Unknown"
if outcome.Opponent != nil {
opponentName = outcome.Opponent.Name
}
}}
<div class={ "flex items-center gap-3 px-3 py-2 rounded-lg", style.rowBg }>
<span class={ "text-xs font-bold w-8 text-center", style.text }>{ style.fullLabel }</span>
<span class="text-sm text-text">vs { opponentName }</span>
if outcome.IsForfeit {
<span class="text-xs text-red ml-auto font-medium">Forfeit</span>
} else if outcome.Score != "" {
<span class="text-sm text-subtext0 ml-auto font-mono">{ outcome.Score }</span>
}
</div>
}
// matchPreviewRosters renders team rosters side-by-side for the match preview.
templ matchPreviewRosters(
fixture *db.Fixture,
rosters map[string][]*db.PlayerWithPlayStatus,
) {
{{
homePlayers := rosters["home"]
awayPlayers := rosters["away"]
}}
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
<h2 class="text-lg font-bold text-text">Team Rosters</h2>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Home Team Roster -->
@previewRosterColumn(fixture.HomeTeam, homePlayers, fixture.Season, fixture.League)
<!-- Away Team Roster -->
@previewRosterColumn(fixture.AwayTeam, awayPlayers, fixture.Season, fixture.League)
</div>
</div>
</div>
}
// previewRosterColumn renders a single team's roster for the match preview.
templ previewRosterColumn(
team *db.Team,
players []*db.PlayerWithPlayStatus,
season *db.Season,
league *db.League,
) {
{{
// Separate managers and regular players
var managers []*db.PlayerWithPlayStatus
var roster []*db.PlayerWithPlayStatus
for _, p := range players {
if p.IsManager {
managers = append(managers, p)
} else {
roster = append(roster, p)
}
}
// Sort roster alphabetically by display name
sort.Slice(roster, func(i, j int) bool {
return roster[i].Player.DisplayName() < roster[j].Player.DisplayName()
})
}}
<div>
<!-- Team Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
if team.Color != "" {
<span
class="w-3.5 h-3.5 rounded-full shrink-0 border border-surface1"
style={ "background-color: " + templ.SafeCSS(team.Color) }
></span>
}
<h3 class="text-md font-bold">
@links.TeamNameLinkInSeason(team, season, league)
</h3>
</div>
<span class="text-xs text-subtext0">
{ fmt.Sprint(len(players)) } players
</span>
</div>
if len(players) == 0 {
<p class="text-subtext1 text-sm text-center py-4">No players on roster.</p>
} else {
<div class="space-y-1">
<!-- Manager(s) -->
for _, p := range managers {
<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface0 border border-surface1">
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium shrink-0">
&#9733;
</span>
<span class="text-sm font-medium">
@links.PlayerLink(p.Player)
</span>
if p.IsFreeAgent {
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FA
</span>
}
</div>
}
<!-- Regular Players -->
for _, p := range roster {
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface0 transition">
<span class="text-sm">
@links.PlayerLink(p.Player)
</span>
if p.IsFreeAgent {
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FA
</span>
}
</div>
}
</div>
}
</div>
}

View File

@@ -2,6 +2,7 @@ package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/baseview" import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt" import "fmt"
templ FixtureReviewResultPage( templ FixtureReviewResultPage(
@@ -22,7 +23,13 @@ templ FixtureReviewResultPage(
<div> <div>
<h1 class="text-2xl font-bold text-text mb-1">Review Match Result</h1> <h1 class="text-2xl font-bold text-text mb-1">Review Match Result</h1>
<p class="text-sm text-subtext1"> <p class="text-sm text-subtext1">
{ fixture.HomeTeam.Name } vs { fixture.AwayTeam.Name } <span>
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
</span>
vs
<span>
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
</span>
<span class="text-subtext0 ml-1"> <span class="text-subtext0 ml-1">
Round { fmt.Sprint(fixture.Round) } Round { fmt.Sprint(fixture.Round) }
</span> </span>
@@ -96,12 +103,16 @@ templ FixtureReviewResultPage(
<div class="p-6"> <div class="p-6">
<div class="flex items-center justify-center gap-8 py-4"> <div class="flex items-center justify-center gap-8 py-4">
<div class="text-center"> <div class="text-center">
<p class="text-sm text-subtext0 mb-1">{ fixture.HomeTeam.Name }</p> <p class="text-sm text-subtext0 mb-1">
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
</p>
<p class="text-4xl font-bold text-text">{ fmt.Sprint(result.HomeScore) }</p> <p class="text-4xl font-bold text-text">{ fmt.Sprint(result.HomeScore) }</p>
</div> </div>
<div class="text-2xl text-subtext0 font-light">—</div> <div class="text-2xl text-subtext0 font-light">—</div>
<div class="text-center"> <div class="text-center">
<p class="text-sm text-subtext0 mb-1">{ fixture.AwayTeam.Name }</p> <p class="text-sm text-subtext0 mb-1">
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
</p>
<p class="text-4xl font-bold text-text">{ fmt.Sprint(result.AwayScore) }</p> <p class="text-4xl font-bold text-text">{ fmt.Sprint(result.AwayScore) }</p>
</div> </div>
</div> </div>
@@ -127,8 +138,8 @@ templ FixtureReviewResultPage(
</div> </div>
<!-- Player Stats Tables --> <!-- Player Stats Tables -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
@reviewTeamStats(fixture.HomeTeam, result, "home") @reviewTeamStats(fixture.HomeTeam, result, "home", fixture.Season, fixture.League)
@reviewTeamStats(fixture.AwayTeam, result, "away") @reviewTeamStats(fixture.AwayTeam, result, "away", fixture.Season, fixture.League)
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden"> <div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
@@ -164,7 +175,7 @@ templ FixtureReviewResultPage(
} }
} }
templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) { templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string, season *db.Season, league *db.League) {
{{ {{
// Collect unique players for this team across all periods // Collect unique players for this team across all periods
// We'll show the period 3 (final/cumulative) stats // We'll show the period 3 (final/cumulative) stats
@@ -197,7 +208,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
} else { } else {
Away — Away —
} }
{ team.Name } @links.TeamNameLinkInSeason(team, season, league)
</h3> </h3>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -205,6 +216,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
<thead class="bg-surface0 border-b border-surface1"> <thead class="bg-surface0 border-b border-surface1">
<tr> <tr>
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th> <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="Periods Played">PP</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="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="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="Saves">SV</th>
@@ -217,10 +229,12 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
<tbody class="divide-y divide-surface1"> <tbody class="divide-y divide-surface1">
for _, ps := range finalStats { for _, ps := range finalStats {
<tr class="hover:bg-surface0 transition-colors"> <tr class="hover:bg-surface0 transition-colors">
<td class="px-3 py-2 text-sm text-text"> <td class="px-3 py-2 text-sm">
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
{ ps.Username } if ps.PlayerID != nil {
if ps.PlayerID == nil { @links.PlayerLinkFromStats(*ps.PlayerID, ps.Username)
} else {
<span class="text-text">{ ps.Username }</span>
<span class="text-yellow text-xs" title="Unmapped player">?</span> <span class="text-yellow text-xs" title="Unmapped player">?</span>
} }
if ps.Stats.IsFreeAgent { if ps.Stats.IsFreeAgent {
@@ -230,6 +244,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
} }
</span> </span>
</td> </td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ intPtrStr(ps.Stats.PeriodsPlayed) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Goals) }</td> <td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Goals) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Assists) }</td> <td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Assists) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Saves) }</td> <td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Saves) }</td>
@@ -241,7 +256,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
} }
if len(finalStats) == 0 { if len(finalStats) == 0 {
<tr> <tr>
<td colspan="8" class="px-3 py-4 text-center text-sm text-subtext1"> <td colspan="9" class="px-3 py-4 text-center text-sm text-subtext1">
No player stats recorded No player stats recorded
</td> </td>
</tr> </tr>
@@ -258,3 +273,20 @@ func intPtrStr(v *int) string {
} }
return fmt.Sprint(*v) return fmt.Sprint(*v)
} }
func ordinal(n int) string {
suffix := "th"
if n%100 >= 11 && n%100 <= 13 {
// 11th, 12th, 13th
} else {
switch n % 10 {
case 1:
suffix = "st"
case 2:
suffix = "nd"
case 3:
suffix = "rd"
}
}
return fmt.Sprintf("%d%s", n, suffix)
}

View File

@@ -3,6 +3,7 @@ package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/permissions" import "git.haelnorr.com/h/oslstats/internal/permissions"
import "git.haelnorr.com/h/oslstats/internal/contexts" import "git.haelnorr.com/h/oslstats/internal/contexts"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt" import "fmt"
templ SeasonLeagueFreeAgentsPage(season *db.Season, league *db.League, freeAgents []*db.SeasonLeagueFreeAgent, availablePlayers []*db.Player) { templ SeasonLeagueFreeAgentsPage(season *db.Season, league *db.League, freeAgents []*db.SeasonLeagueFreeAgent, availablePlayers []*db.Player) {
@@ -44,7 +45,6 @@ templ SeasonLeagueFreeAgents(season *db.Season, league *db.League, freeAgents []
<thead class="bg-mantle border-b border-surface1"> <thead class="bg-mantle border-b border-surface1">
<tr> <tr>
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Player</th> <th class="px-4 py-3 text-left text-sm font-semibold text-text">Player</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Registered By</th>
if canRemove { if canRemove {
<th class="px-4 py-3 text-right text-sm font-semibold text-text">Actions</th> <th class="px-4 py-3 text-right text-sm font-semibold text-text">Actions</th>
} }
@@ -53,19 +53,14 @@ templ SeasonLeagueFreeAgents(season *db.Season, league *db.League, freeAgents []
<tbody class="divide-y divide-surface1"> <tbody class="divide-y divide-surface1">
for _, fa := range freeAgents { for _, fa := range freeAgents {
<tr class="hover:bg-surface1 transition-colors"> <tr class="hover:bg-surface1 transition-colors">
<td class="px-4 py-3 text-sm text-text"> <td class="px-4 py-3 text-sm">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
{ fa.Player.DisplayName() } @links.PlayerLink(fa.Player)
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium"> <span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FREE AGENT FREE AGENT
</span> </span>
</span> </span>
</td> </td>
<td class="px-4 py-3 text-sm text-subtext0">
if fa.RegisteredBy != nil {
{ fa.RegisteredBy.Username }
}
</td>
if canRemove { if canRemove {
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<form <form

View File

@@ -1,15 +1,321 @@
package seasonsview package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt"
templ SeasonLeagueStatsPage(season *db.Season, league *db.League) { templ SeasonLeagueStatsPage(
season *db.Season,
league *db.League,
topGoals []*db.LeagueTopGoalScorer,
topAssists []*db.LeagueTopAssister,
topSaves []*db.LeagueTopSaver,
allStats []*db.LeaguePlayerStats,
) {
@SeasonLeagueLayout("stats", season, league) { @SeasonLeagueLayout("stats", season, league) {
@SeasonLeagueStats() @SeasonLeagueStats(season, league, topGoals, topAssists, topSaves, allStats)
} }
} }
templ SeasonLeagueStats() { templ SeasonLeagueStats(
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center"> season *db.Season,
<p class="text-subtext0 text-lg">Coming Soon...</p> league *db.League,
topGoals []*db.LeagueTopGoalScorer,
topAssists []*db.LeagueTopAssister,
topSaves []*db.LeagueTopSaver,
allStats []*db.LeaguePlayerStats,
) {
<script src="/static/js/sortable-table.js"></script>
if len(topGoals) == 0 && len(topAssists) == 0 && len(topSaves) == 0 && len(allStats) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No stats available yet.</p>
<p class="text-subtext1 text-sm mt-2">Player statistics will appear here once games are finalized.</p>
</div>
} else {
<div class="space-y-8">
<!-- Trophy Leaders Section -->
if len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 {
<div class="space-y-4">
<h2 class="text-xl font-bold text-text text-center">Trophy Leaders</h2>
<!-- Triangle layout: two side-by-side on wide screens, saves centered below -->
<div class="flex flex-col items-center gap-6">
<!-- Top row: Goals and Assists side by side when room allows -->
<div class="flex flex-col lg:flex-row gap-6 justify-center items-center lg:items-start">
@topGoalScorersTable(season, league, topGoals)
@topAssistersTable(season, league, topAssists)
</div>
<!-- Bottom row: Saves centered -->
@topSaversTable(season, league, topSaves)
</div>
</div>
}
<!-- All Stats Section -->
if len(allStats) > 0 {
<div class="space-y-4">
<h2 class="text-xl font-bold text-text text-center">All Stats</h2>
@allStatsTable(season, league, allStats)
</div>
}
</div>
}
}
templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.LeagueTopGoalScorer) {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
<div class="bg-mantle border-b border-surface1 px-4 py-3">
<h3 class="text-sm font-semibold text-text">
Top Goal Scorers
</h3>
</div>
<!-- Sorting key -->
<div class="bg-mantle border-b border-surface1 px-4 py-1.5 flex items-center gap-3 text-xs text-subtext0">
<span class="font-semibold text-subtext1">Sort:</span>
<span>G &#8595;</span>
<span>PP &#8593;</span>
<span>SH &#8593;</span>
</div>
if len(goals) == 0 {
<div class="p-6 text-center">
<p class="text-subtext0 text-sm">No goal data available yet.</p>
</div>
} else {
<table>
<thead class="bg-mantle border-b border-surface1">
<tr>
<th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Team</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-blue" title="Goals">G</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for i, gs := range goals {
<tr class="hover:bg-surface1 transition-colors">
<td class="px-3 py-2 text-center text-sm font-medium text-subtext0">
{ fmt.Sprint(i + 1) }
</td>
<td class="px-3 py-2 text-sm font-medium whitespace-nowrap">
@links.PlayerLinkFromStats(gs.PlayerID, gs.PlayerName)
</td>
<td class="px-3 py-2 text-sm whitespace-nowrap">
@teamColorName(gs.TeamID, gs.TeamName, gs.TeamColor, season, league)
</td>
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(gs.Goals) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(gs.PeriodsPlayed) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(gs.Shots) }</td>
</tr>
}
</tbody>
</table>
}
</div> </div>
} }
templ topAssistersTable(season *db.Season, league *db.League, assists []*db.LeagueTopAssister) {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
<div class="bg-mantle border-b border-surface1 px-4 py-3">
<h3 class="text-sm font-semibold text-text">
Top Assisters
</h3>
</div>
<!-- Sorting key -->
<div class="bg-mantle border-b border-surface1 px-4 py-1.5 flex items-center gap-3 text-xs text-subtext0">
<span class="font-semibold text-subtext1">Sort:</span>
<span>A &#8595;</span>
<span>PP &#8593;</span>
<span>PA &#8595;</span>
</div>
if len(assists) == 0 {
<div class="p-6 text-center">
<p class="text-subtext0 text-sm">No assist data available yet.</p>
</div>
} else {
<table>
<thead class="bg-mantle border-b border-surface1">
<tr>
<th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Team</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-blue" title="Assists">A</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Primary Assists">PA</th>
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for i, as := range assists {
<tr class="hover:bg-surface1 transition-colors">
<td class="px-3 py-2 text-center text-sm font-medium text-subtext0">
{ fmt.Sprint(i + 1) }
</td>
<td class="px-3 py-2 text-sm font-medium whitespace-nowrap">
@links.PlayerLinkFromStats(as.PlayerID, as.PlayerName)
</td>
<td class="px-3 py-2 text-sm whitespace-nowrap">
@teamColorName(as.TeamID, as.TeamName, as.TeamColor, season, league)
</td>
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(as.Assists) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(as.PeriodsPlayed) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(as.PrimaryAssists) }</td>
</tr>
}
</tbody>
</table>
}
</div>
}
templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTopSaver) {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
<div class="bg-mantle border-b border-surface1 px-4 py-3">
<h3 class="text-sm font-semibold text-text">
Top Saves
</h3>
</div>
<!-- Sorting key -->
<div class="bg-mantle border-b border-surface1 px-4 py-1.5 flex items-center gap-3 text-xs text-subtext0">
<span class="font-semibold text-subtext1">Sort:</span>
<span>SV &#8595;</span>
<span>PP &#8593;</span>
<span>BLK &#8595;</span>
</div>
if len(saves) == 0 {
<div class="p-6 text-center">
<p class="text-subtext0 text-sm">No save data available yet.</p>
</div>
} else {
<table>
<thead class="bg-mantle border-b border-surface1">
<tr>
<th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Team</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-blue" title="Saves">SV</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BLK</th>
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for i, sv := range saves {
<tr class="hover:bg-surface1 transition-colors">
<td class="px-3 py-2 text-center text-sm font-medium text-subtext0">
{ fmt.Sprint(i + 1) }
</td>
<td class="px-3 py-2 text-sm font-medium whitespace-nowrap">
@links.PlayerLinkFromStats(sv.PlayerID, sv.PlayerName)
</td>
<td class="px-3 py-2 text-sm whitespace-nowrap">
@teamColorName(sv.TeamID, sv.TeamName, sv.TeamColor, season, league)
</td>
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(sv.Saves) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(sv.PeriodsPlayed) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(sv.Blocks) }</td>
</tr>
}
</tbody>
</table>
}
</div>
}
templ allStatsTable(season *db.Season, league *db.League, allStats []*db.LeaguePlayerStats) {
<div
class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"
x-data="sortableTable('score', 'desc')"
>
<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-3 py-2 text-left text-xs font-semibold text-text">Team</th>
@sortableCol("gp", "GP", "Games Played")
@sortableCol("pp", "PP", "Periods Played")
@sortableCol("score", "SC", "Score")
@sortableCol("goals", "G", "Goals")
@sortableCol("assists", "A", "Assists")
@sortableCol("pa", "PA", "Primary Assists")
@sortableCol("sa", "SA", "Secondary Assists")
@sortableCol("saves", "SV", "Saves")
@sortableCol("shots", "SH", "Shots")
@sortableCol("blocks", "BLK", "Blocks")
@sortableCol("passes", "PAS", "Passes")
</tr>
</thead>
<tbody class="divide-y divide-surface1" x-ref="tbody">
for _, ps := range allStats {
<tr
class="hover:bg-surface1 transition-colors"
data-name={ ps.PlayerName }
data-team={ ps.TeamName }
data-gp={ fmt.Sprint(ps.GamesPlayed) }
data-pp={ fmt.Sprint(ps.PeriodsPlayed) }
data-score={ fmt.Sprint(ps.Score) }
data-goals={ fmt.Sprint(ps.Goals) }
data-assists={ fmt.Sprint(ps.Assists) }
data-pa={ fmt.Sprint(ps.PrimaryAssists) }
data-sa={ fmt.Sprint(ps.SecondaryAssists) }
data-saves={ fmt.Sprint(ps.Saves) }
data-shots={ fmt.Sprint(ps.Shots) }
data-blocks={ fmt.Sprint(ps.Blocks) }
data-passes={ fmt.Sprint(ps.Passes) }
>
<td class="px-3 py-2 text-sm font-medium">
@links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName)
</td>
<td class="px-3 py-2 text-sm">
@teamColorName(ps.TeamID, ps.TeamName, ps.TeamColor, season, league)
</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 text-subtext0">{ fmt.Sprint(ps.PeriodsPlayed) }</td>
<td class="px-2 py-2 text-center text-sm text-text font-medium">{ 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-subtext0">{ fmt.Sprint(ps.PrimaryAssists) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.SecondaryAssists) }</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-subtext0">{ fmt.Sprint(ps.Shots) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.Blocks) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.Passes) }</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
templ sortableCol(field string, label string, title string) {
<th
class="px-2 py-2 text-center text-xs font-semibold text-text select-none hover:cursor-pointer hover:text-blue transition-colors"
title={ title }
@click={ fmt.Sprintf("sort('%s')", field) }
>
<span class="inline-flex items-center gap-0.5">
{ label }
<template x-if={ fmt.Sprintf("sortField === '%s'", field) }>
<span class="text-blue" x-text={ "sortDir === 'asc' ? '↑' : '↓'" }></span>
</template>
</span>
</th>
}
templ teamColorName(teamID int, teamName string, teamColor string, season *db.Season, league *db.League) {
if teamID > 0 && teamName != "" {
<a
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams/%d", season.ShortName, league.ShortName, teamID)) }
class="flex items-center gap-2 hover:text-blue transition"
>
if teamColor != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(teamColor) }
></span>
}
<span class="text-sm font-medium whitespace-nowrap">{ teamName }</span>
</a>
} else {
<span class="text-sm text-subtext0 italic">—</span>
}
}

View File

@@ -1,15 +1,16 @@
package seasonsview package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt" import "fmt"
templ SeasonLeagueTablePage(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) { templ SeasonLeagueTablePage(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) {
@SeasonLeagueLayout("table", season, league) { @SeasonLeagueLayout("table", season, league) {
@SeasonLeagueTable(leaderboard) @SeasonLeagueTable(season, league, leaderboard)
} }
} }
templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) { templ SeasonLeagueTable(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) {
if len(leaderboard) == 0 { if len(leaderboard) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center"> <div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No teams in this league yet.</p> <p class="text-subtext0 text-lg">No teams in this league yet.</p>
@@ -43,7 +44,7 @@ templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) {
</thead> </thead>
<tbody class="divide-y divide-surface1"> <tbody class="divide-y divide-surface1">
for _, entry := range leaderboard { for _, entry := range leaderboard {
@leaderboardRow(entry) @leaderboardRow(entry, season, league)
} }
</tbody> </tbody>
</table> </table>
@@ -52,7 +53,7 @@ templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) {
} }
} }
templ leaderboardRow(entry *db.LeaderboardEntry) { templ leaderboardRow(entry *db.LeaderboardEntry, season *db.Season, league *db.League) {
{{ {{
r := entry.Record r := entry.Record
goalDiff := r.GoalsFor - r.GoalsAgainst goalDiff := r.GoalsFor - r.GoalsAgainst
@@ -68,15 +69,7 @@ templ leaderboardRow(entry *db.LeaderboardEntry) {
{ fmt.Sprint(entry.Position) } { fmt.Sprint(entry.Position) }
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center gap-2"> @links.TeamLinkInSeason(entry.Team, season, league)
if entry.Team.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(entry.Team.Color) }
></span>
}
<span class="text-sm font-medium text-text">{ entry.Team.Name }</span>
</div>
</td> </td>
<td class="px-3 py-3 text-center text-sm text-subtext0"> <td class="px-3 py-3 text-center text-sm text-subtext0">
{ fmt.Sprint(r.Played) } { fmt.Sprint(r.Played) }

View File

@@ -4,11 +4,12 @@ import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/permissions" import "git.haelnorr.com/h/oslstats/internal/permissions"
import "git.haelnorr.com/h/oslstats/internal/contexts" import "git.haelnorr.com/h/oslstats/internal/contexts"
import "git.haelnorr.com/h/oslstats/internal/view/baseview" import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt" import "fmt"
import "sort" import "sort"
import "time" import "time"
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) { 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, position int, totalTeams int) {
{{ {{
team := twr.Team team := twr.Team
season := twr.Season season := twr.Season
@@ -42,25 +43,68 @@ templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture,
</div> </div>
</div> </div>
</div> </div>
<a <div class="flex items-center gap-2">
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName)) } <a
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center href={ templ.SafeURL(fmt.Sprintf("/teams/%d", team.ID)) }
bg-surface1 hover:bg-surface2 text-text transition" class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
> bg-surface0 border border-surface1 hover:bg-surface1 text-subtext0 hover:text-text transition text-sm"
Back to Teams >
</a> View All Seasons
</a>
<a
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName)) }
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-surface1 hover:bg-surface2 text-text transition"
>
Back to Teams
</a>
</div>
</div> </div>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="bg-crust p-6"> <div class="bg-crust p-6">
<!-- Top row: Roster (left) + Fixtures (right) --> {{
// 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]
}
}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Top Left: Team Standing -->
@teamRecordCard(record, position, totalTeams)
<!-- Top Right: Results -->
@teamResultsSection(twr.Team, recentResults, resultMap)
<!-- Bottom Left: Roster -->
@TeamRosterSection(twr, available) @TeamRosterSection(twr, available)
@teamFixturesPane(twr.Team, fixtures, scheduleMap, resultMap) <!-- Bottom Right: Upcoming -->
@teamUpcomingSection(twr.Team, upcoming, scheduleMap)
</div> </div>
<!-- Stats below both --> <!-- Player Stats (full width) -->
<div class="mt-6"> <div class="mt-6">
@teamStatsSection(record, playerStats) @playerStatsSection(playerStats)
</div> </div>
</div> </div>
</div> </div>
@@ -111,7 +155,9 @@ templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1"> <div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
if twr.Manager != nil { if twr.Manager != nil {
<div class="px-4 py-3 flex items-center justify-between"> <div class="px-4 py-3 flex items-center justify-between">
<span class="text-text font-medium">{ twr.Manager.Name }</span> <span class="font-medium">
@links.PlayerLink(twr.Manager)
</span>
<span class="text-xs px-2 py-0.5 bg-yellow/20 text-yellow rounded font-medium"> <span class="text-xs px-2 py-0.5 bg-yellow/20 text-yellow rounded font-medium">
&#9733; Manager &#9733; Manager
</span> </span>
@@ -119,7 +165,7 @@ templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) {
} }
for _, player := range rosterPlayers { for _, player := range rosterPlayers {
<div class="px-4 py-3"> <div class="px-4 py-3">
<span class="text-text">{ player.Name }</span> @links.PlayerLink(player)
</div> </div>
} }
</div> </div>
@@ -396,68 +442,45 @@ templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPl
</script> </script>
} }
templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) { templ teamResultsSection(team *db.Team, recentResults []*db.Fixture, resultMap map[int]*db.FixtureResult) {
{{ <section>
// Split fixtures into upcoming and completed <div class="flex justify-between items-center mb-4">
var upcoming []*db.Fixture <h2 class="text-2xl font-bold text-text">
var completed []*db.Fixture Results
for _, f := range fixtures { <span class="text-sm font-normal text-subtext0">(last 5)</span>
if _, hasResult := resultMap[f.ID]; hasResult { </h2>
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> </div>
<!-- Upcoming --> if len(recentResults) == 0 {
<div> <div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<h2 class="text-2xl font-bold text-text mb-4">Upcoming</h2> <p class="text-subtext0 text-lg">No results yet.</p>
if len(upcoming) == 0 { <p class="text-subtext1 text-sm mt-2">Match results will appear here once games are played.</p>
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center"> </div>
<p class="text-subtext0 text-lg">No upcoming fixtures.</p> } else {
</div> <div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
} else { for _, fixture := range recentResults {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1"> @teamResultRow(team, fixture, resultMap)
for _, fixture := range upcoming { }
@teamFixtureRow(team, fixture, scheduleMap) </div>
} }
</div> </section>
} }
templ teamUpcomingSection(team *db.Team, upcoming []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
<section>
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-text">Upcoming</h2>
</div> </div>
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 upcoming {
@teamFixtureRow(team, fixture, scheduleMap)
}
</div>
}
</section> </section>
} }
@@ -586,65 +609,98 @@ templ teamResultRow(team *db.Team, fixture *db.Fixture, resultMap map[int]*db.Fi
</a> </a>
} }
templ teamStatsSection(record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) { templ teamRecordCard(record *db.TeamRecord, position int, totalTeams int) {
<section> <section>
<div class="mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-text">Stats</h2> <h2 class="text-2xl font-bold text-text">Standing</h2>
</div> </div>
if record.Played == 0 { if record.Played == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center"> <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-subtext0 text-lg">No games played yet.</p>
<p class="text-subtext1 text-sm mt-2">Team statistics will appear here once games are played.</p>
</div> </div>
} else { } else {
<!-- Team Record Summary --> <div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden mb-4"> <!-- Position & Points Header -->
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 divide-x divide-surface1"> <div class="flex items-center justify-between px-6 py-5 border-b border-surface1">
<div class="flex items-center gap-3">
<span class="text-4xl font-bold text-text">{ ordinal(position) }</span>
<div>
<p class="text-xs text-subtext0 uppercase font-medium">Position</p>
<p class="text-sm text-subtext1">of { fmt.Sprint(totalTeams) } teams</p>
</div>
</div>
<div class="text-right">
<p class="text-xs text-subtext0 uppercase font-medium">Points</p>
<p class="text-3xl font-bold text-blue">{ fmt.Sprint(record.Points) }</p>
</div>
</div>
<!-- Record Grid -->
<div class="grid grid-cols-4 divide-x divide-surface1">
@statCell("W", fmt.Sprint(record.Wins), "text-green")
@statCell("OTW", fmt.Sprint(record.OvertimeWins), "text-teal")
@statCell("OTL", fmt.Sprint(record.OvertimeLosses), "text-peach")
@statCell("L", fmt.Sprint(record.Losses), "text-red")
</div>
<!-- Goals Row -->
<div class="grid grid-cols-3 divide-x divide-surface1 border-t border-surface1">
@statCell("Played", fmt.Sprint(record.Played), "") @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("GF", fmt.Sprint(record.GoalsFor), "")
@statCell("GA", fmt.Sprint(record.GoalsAgainst), "") @statCell("GA", fmt.Sprint(record.GoalsAgainst), "")
</div> </div>
</div> </div>
<!-- Player Stats Leaderboard --> }
if len(playerStats) > 0 { </section>
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"> }
<div class="overflow-x-auto">
<table class="w-full"> templ playerStatsSection(playerStats []*db.AggregatedPlayerStats) {
<thead class="bg-mantle border-b border-surface1"> <section>
<tr> <div class="flex justify-between items-center mb-4">
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th> <h2 class="text-2xl font-bold text-text">Player Stats</h2>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Games Played">GP</th> </div>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th> if len(playerStats) == 0 {
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th> <div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th> <p class="text-subtext0 text-lg">No player stats yet.</p>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th> <p class="text-subtext1 text-sm mt-2">Player statistics will appear here once games are played.</p>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th> </div>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th> } else {
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th> <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="Periods Played">PP</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">
@links.PlayerLinkFromStats(ps.PlayerID, 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 text-subtext0">{ fmt.Sprint(ps.PeriodsPlayed) }</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> </tr>
</thead> }
<tbody class="divide-y divide-surface1"> </tbody>
for _, ps := range playerStats { </table>
<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> </div>
} </div>
} }
</section> </section>
} }

View File

@@ -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) {
<div class="max-w-screen-xl mx-auto px-4 py-8">
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<!-- Header -->
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div class="flex items-center gap-4">
if team.Color != "" {
<div
class="w-12 h-12 rounded-full border-2 border-surface1 shrink-0"
style={ "background-color: " + templ.SafeCSS(team.Color) }
></div>
}
<div>
<h1 class="text-4xl font-bold text-text">{ team.Name }</h1>
<div class="flex items-center gap-2 mt-2 flex-wrap">
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
{ team.ShortName }
</span>
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
{ team.AltShortName }
</span>
</div>
</div>
</div>
<a
href="/teams"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-surface1 hover:bg-surface2 text-text transition"
>
Back to Teams
</a>
</div>
</div>
<!-- Tab Navigation -->
<nav class="bg-surface0 border-b border-surface1">
<ul class="flex flex-wrap">
@teamDetailTab("seasons", "Seasons", activeTab, team)
@teamDetailTab("stats", "Player Stats", activeTab, team)
</ul>
</nav>
</div>
<!-- Tab Content -->
<div class="mt-6">
if activeTab == "seasons" {
@TeamDetailSeasons(team, seasonInfos)
} else if activeTab == "stats" {
@TeamDetailPlayerStats(playerStats)
}
</div>
</div>
}
}
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)
}
}}
<li class="inline-block">
<a
href={ templ.SafeURL(url) }
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
>
{ label }
</a>
</li>
}

View File

@@ -0,0 +1,130 @@
package teamsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt"
import "sort"
templ TeamDetailPlayerStats(playerStats []*db.TeamAllTimePlayerStats) {
if len(playerStats) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No player stats yet.</p>
<p class="text-subtext1 text-sm mt-2">Player statistics will appear here once games are played.</p>
</div>
} else {
<div x-data="{ activeView: 'goals' }">
<!-- Sub-view Tabs -->
<div class="flex gap-2 mb-4">
<button
@click="activeView = 'goals'"
:class="activeView === 'goals'
? 'bg-blue text-mantle'
: 'bg-surface0 border border-surface1 text-subtext0 hover:text-text hover:bg-surface1'"
class="px-4 py-2 rounded-lg font-medium text-sm transition hover:cursor-pointer"
>
Goals
</button>
<button
@click="activeView = 'assists'"
:class="activeView === 'assists'
? 'bg-blue text-mantle'
: 'bg-surface0 border border-surface1 text-subtext0 hover:text-text hover:bg-surface1'"
class="px-4 py-2 rounded-lg font-medium text-sm transition hover:cursor-pointer"
>
Assists
</button>
<button
@click="activeView = 'saves'"
:class="activeView === 'saves'
? 'bg-blue text-mantle'
: 'bg-surface0 border border-surface1 text-subtext0 hover:text-text hover:bg-surface1'"
class="px-4 py-2 rounded-lg font-medium text-sm transition hover:cursor-pointer"
>
Saves
</button>
</div>
<!-- Goals View -->
<div x-show="activeView === 'goals'">
@playerStatsTable(playerStats, "goals")
</div>
<!-- Assists View -->
<div x-show="activeView === 'assists'" style="display: none;">
@playerStatsTable(playerStats, "assists")
</div>
<!-- Saves View -->
<div x-show="activeView === 'saves'" style="display: none;">
@playerStatsTable(playerStats, "saves")
</div>
</div>
}
}
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
}}
<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-center text-xs font-semibold text-subtext0 w-10">#</th>
<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="Seasons Played">SZN</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-blue" title={ statLabel }>{ statShort }</th>
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for i, ps := range sorted {
<tr class="hover:bg-surface1 transition-colors">
<td class="px-3 py-2 text-center text-sm font-medium text-subtext0">
{ fmt.Sprint(i + 1) }
</td>
<td class="px-3 py-2 text-sm font-medium">
@links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName)
</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.SeasonsPlayed) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.PeriodsPlayed) }</td>
if statType == "goals" {
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(ps.Goals) }</td>
} else if statType == "assists" {
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(ps.Assists) }</td>
} else {
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(ps.Saves) }</td>
}
</tr>
}
</tbody>
</table>
</div>
</div>
}

View File

@@ -0,0 +1,103 @@
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 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No season history yet.</p>
<p class="text-subtext1 text-sm mt-2">This team has not participated in any seasons.</p>
</div>
} else {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
for _, info := range seasonInfos {
@teamSeasonCard(team, info)
}
</div>
}
}
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,
)
}}
<a
href={ templ.SafeURL(detailURL) }
class="bg-mantle border border-surface1 rounded-lg overflow-hidden
hover:bg-surface0 transition hover:cursor-pointer block"
>
<!-- Card Header -->
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="text-lg font-bold text-text">{ info.Season.Name }</h3>
<span class="text-subtext0 text-sm">—</span>
<span class="text-subtext0 text-sm">{ info.League.Name }</span>
</div>
@seasonsview.StatusBadge(info.Season, true, true)
</div>
<!-- Card Body -->
<div class="p-4">
<!-- Position & Points Row -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<!-- Position Badge -->
<div class="flex items-center gap-1.5">
<span class="text-xs text-subtext0 uppercase font-medium">Position</span>
<span class="text-2xl font-bold text-text">
{ ordinal(info.Position) }
</span>
<span class="text-sm text-subtext0">
/ { fmt.Sprint(info.TotalTeams) }
</span>
</div>
</div>
<!-- Points -->
<div class="text-right">
<span class="text-xs text-subtext0 uppercase font-medium">Points</span>
<p class="text-2xl font-bold text-blue">{ fmt.Sprint(info.Record.Points) }</p>
</div>
</div>
<!-- Record Row -->
<div class="grid grid-cols-4 gap-2 text-center">
<div class="bg-surface0 rounded px-2 py-1.5">
<p class="text-xs text-subtext0 font-medium">W</p>
<p class="text-sm font-bold text-green">{ fmt.Sprint(info.Record.Wins) }</p>
</div>
<div class="bg-surface0 rounded px-2 py-1.5">
<p class="text-xs text-subtext0 font-medium">OTW</p>
<p class="text-sm font-bold text-teal">{ fmt.Sprint(info.Record.OvertimeWins) }</p>
</div>
<div class="bg-surface0 rounded px-2 py-1.5">
<p class="text-xs text-subtext0 font-medium">OTL</p>
<p class="text-sm font-bold text-peach">{ fmt.Sprint(info.Record.OvertimeLosses) }</p>
</div>
<div class="bg-surface0 rounded px-2 py-1.5">
<p class="text-xs text-subtext0 font-medium">L</p>
<p class="text-sm font-bold text-red">{ fmt.Sprint(info.Record.Losses) }</p>
</div>
</div>
</div>
</a>
}
func ordinal(n int) string {
suffix := "th"
if n%100 >= 11 && n%100 <= 13 {
// 11th, 12th, 13th
} else {
switch n % 10 {
case 1:
suffix = "st"
case 2:
suffix = "nd"
case 3:
suffix = "rd"
}
}
return fmt.Sprintf("%d%s", n, suffix)
}

View File

@@ -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/contexts"
import "git.haelnorr.com/h/oslstats/internal/permissions" import "git.haelnorr.com/h/oslstats/internal/permissions"
import "github.com/uptrace/bun" import "github.com/uptrace/bun"
import "fmt"
templ ListPage(teams *db.List[db.Team]) { templ ListPage(teams *db.List[db.Team]) {
@baseview.Layout("Teams") { @baseview.Layout("Teams") {
@@ -80,8 +81,10 @@ templ TeamsList(teams *db.List[db.Team]) {
<!-- Card grid --> <!-- Card grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, t := range teams.Items { for _, t := range teams.Items {
<div <a
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0 transition-colors flex flex-col" href={ templ.SafeURL(fmt.Sprintf("/teams/%d", t.ID)) }
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0
transition-colors flex flex-col hover:cursor-pointer"
> >
<!-- Header: Name with color indicator --> <!-- Header: Name with color indicator -->
<div class="flex justify-between items-start mb-3"> <div class="flex justify-between items-start mb-3">
@@ -102,7 +105,7 @@ templ TeamsList(teams *db.List[db.Team]) {
{ t.AltShortName } { t.AltShortName }
</span> </span>
</div> </div>
</div> </a>
} }
</div> </div>
<!-- Pagination controls --> <!-- Pagination controls -->