diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go index 390d69e..5a14eeb 100644 --- a/internal/db/fixture_result.go +++ b/internal/db/fixture_result.go @@ -381,16 +381,17 @@ func GetFinalizedResultsForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs [ // AggregatedPlayerStats holds summed stats for a player across multiple fixtures. type AggregatedPlayerStats struct { - PlayerID int `bun:"player_id"` - PlayerName string `bun:"player_name"` - GamesPlayed int `bun:"games_played"` - Score int `bun:"total_score"` - Goals int `bun:"total_goals"` - Assists int `bun:"total_assists"` - Saves int `bun:"total_saves"` - Shots int `bun:"total_shots"` - Blocks int `bun:"total_blocks"` - Passes int `bun:"total_passes"` + PlayerID int `bun:"player_id"` + PlayerName string `bun:"player_name"` + GamesPlayed int `bun:"games_played"` + PeriodsPlayed int `bun:"total_periods_played"` + Score int `bun:"total_score"` + Goals int `bun:"total_goals"` + Assists int `bun:"total_assists"` + Saves int `bun:"total_saves"` + Shots int `bun:"total_shots"` + Blocks int `bun:"total_blocks"` + Passes int `bun:"total_passes"` } // GetAggregatedPlayerStatsForTeam returns aggregated period-3 stats for all mapped @@ -411,6 +412,7 @@ func GetAggregatedPlayerStatsForTeam( 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.score), 0) AS total_score, COALESCE(SUM(frps.goals), 0) AS total_goals, COALESCE(SUM(frps.assists), 0) AS total_assists, @@ -435,6 +437,315 @@ func GetAggregatedPlayerStatsForTeam( 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. type TeamRecord struct { Played int diff --git a/internal/db/match_preview.go b/internal/db/match_preview.go new file mode 100644 index 0000000..beb2c5c --- /dev/null +++ b/internal/db/match_preview.go @@ -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 +} diff --git a/internal/db/player.go b/internal/db/player.go index 7577b2d..48466e7 100644 --- a/internal/db/player.go +++ b/internal/db/player.go @@ -99,6 +99,243 @@ func UpdatePlayerSlapID(ctx context.Context, tx bun.Tx, playerID int, slapID uin 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) { players, err := GetList[Player](tx).Relation("User"). Join("LEFT JOIN team_rosters tr ON tr.player_id = p.id"). diff --git a/internal/db/team.go b/internal/db/team.go index 52174db..b3dde40 100644 --- a/internal/db/team.go +++ b/internal/db/team.go @@ -2,6 +2,7 @@ package db import ( "context" + "sort" "github.com/pkg/errors" "github.com/uptrace/bun" @@ -72,3 +73,149 @@ func (t *Team) InSeason(seasonID int) bool { } return false } + +// TeamSeasonInfo holds information about a team's participation in a specific season+league. +type TeamSeasonInfo struct { + Season *Season + League *League + Record *TeamRecord + TotalTeams int + Position int +} + +// GetTeamSeasonParticipation returns all season+league combos the team participated in, +// with computed records, positions, and total team counts. +func GetTeamSeasonParticipation( + ctx context.Context, + tx bun.Tx, + teamID int, +) ([]*TeamSeasonInfo, error) { + if teamID == 0 { + return nil, errors.New("teamID not provided") + } + + // Get all participations for this team + var participations []*TeamParticipation + err := tx.NewSelect(). + Model(&participations). + Where("team_id = ?", teamID). + Relation("Season", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Relation("Leagues") + }). + Relation("League"). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect participations") + } + + var results []*TeamSeasonInfo + + for _, p := range participations { + // Get all teams in this season+league for position calculation + var teams []*Team + err := tx.NewSelect(). + Model(&teams). + Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id"). + Where("tp.season_id = ? AND tp.league_id = ?", p.SeasonID, p.LeagueID). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect teams") + } + + // Get all fixtures for this season+league + fixtures, err := GetAllocatedFixtures(ctx, tx, p.SeasonID, p.LeagueID) + if err != nil { + return nil, errors.Wrap(err, "GetAllocatedFixtures") + } + + fixtureIDs := make([]int, len(fixtures)) + for i, f := range fixtures { + fixtureIDs[i] = f.ID + } + + resultMap, err := GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs) + if err != nil { + return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures") + } + + // Compute leaderboard to get position + leaderboard := ComputeLeaderboard(teams, fixtures, resultMap) + + var position int + var record *TeamRecord + for _, entry := range leaderboard { + if entry.Team.ID == teamID { + position = entry.Position + record = entry.Record + break + } + } + if record == nil { + record = &TeamRecord{} + } + + results = append(results, &TeamSeasonInfo{ + Season: p.Season, + League: p.League, + Record: record, + TotalTeams: len(teams), + Position: position, + }) + } + + // Sort by season start date descending (newest first) + sort.Slice(results, func(i, j int) bool { + return results[i].Season.StartDate.After(results[j].Season.StartDate) + }) + + return results, nil +} + +// TeamAllTimePlayerStats holds aggregated all-time stats for a player on a team. +type TeamAllTimePlayerStats struct { + PlayerID int `bun:"player_id"` + PlayerName string `bun:"player_name"` + SeasonsPlayed int `bun:"seasons_played"` + PeriodsPlayed int `bun:"total_periods_played"` + Goals int `bun:"total_goals"` + Assists int `bun:"total_assists"` + Saves int `bun:"total_saves"` +} + +// GetTeamAllTimePlayerStats returns aggregated all-time stats for all players +// who have ever played for a given team across all seasons. +func GetTeamAllTimePlayerStats( + ctx context.Context, + tx bun.Tx, + teamID int, +) ([]*TeamAllTimePlayerStats, error) { + if teamID == 0 { + return nil, errors.New("teamID not provided") + } + + var stats []*TeamAllTimePlayerStats + err := tx.NewRaw(` + SELECT + frps.player_id AS player_id, + COALESCE(p.name, frps.player_username) AS player_name, + COUNT(DISTINCT s.id) AS seasons_played, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, + COALESCE(SUM(frps.goals), 0) AS total_goals, + COALESCE(SUM(frps.assists), 0) AS total_assists, + COALESCE(SUM(frps.saves), 0) AS total_saves + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + JOIN fixtures f ON f.id = fr.fixture_id + JOIN seasons s ON s.id = f.season_id + LEFT JOIN players p ON p.id = frps.player_id + WHERE fr.finalized = true + AND frps.team_id = ? + AND frps.period_num = 3 + AND frps.player_id IS NOT NULL + GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) + `, teamID).Scan(ctx, &stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index be3d0b6..b9f0c88 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -35,6 +35,8 @@ --text-3xl--line-height: calc(2.25 / 1.875); --text-4xl: 2.25rem; --text-4xl--line-height: calc(2.5 / 2.25); + --text-5xl: 3rem; + --text-5xl--line-height: 1; --text-6xl: 3.75rem; --text-6xl--line-height: 1; --text-9xl: 8rem; @@ -47,6 +49,7 @@ --tracking-tight: -0.025em; --tracking-wider: 0.05em; --leading-relaxed: 1.625; + --radius-md: 0.375rem; --radius-lg: 0.5rem; --radius-xl: 0.75rem; --ease-in: cubic-bezier(0.4, 0, 1, 1); @@ -450,6 +453,9 @@ .h-3 { height: calc(var(--spacing) * 3); } + .h-3\.5 { + height: calc(var(--spacing) * 3.5); + } .h-4 { height: calc(var(--spacing) * 4); } @@ -459,9 +465,15 @@ .h-6 { height: calc(var(--spacing) * 6); } + .h-9 { + height: calc(var(--spacing) * 9); + } .h-12 { height: calc(var(--spacing) * 12); } + .h-14 { + height: calc(var(--spacing) * 14); + } .h-16 { height: calc(var(--spacing) * 16); } @@ -510,6 +522,9 @@ .w-3 { width: calc(var(--spacing) * 3); } + .w-3\.5 { + width: calc(var(--spacing) * 3.5); + } .w-4 { width: calc(var(--spacing) * 4); } @@ -519,18 +534,30 @@ .w-6 { width: calc(var(--spacing) * 6); } + .w-8 { + width: calc(var(--spacing) * 8); + } + .w-9 { + width: calc(var(--spacing) * 9); + } .w-10 { width: calc(var(--spacing) * 10); } .w-12 { width: calc(var(--spacing) * 12); } + .w-14 { + width: calc(var(--spacing) * 14); + } .w-20 { width: calc(var(--spacing) * 20); } .w-26 { width: calc(var(--spacing) * 26); } + .w-28 { + width: calc(var(--spacing) * 28); + } .w-48 { width: calc(var(--spacing) * 48); } @@ -636,6 +663,9 @@ .animate-spin { animation: var(--animate-spin); } + .cursor-default { + cursor: default; + } .cursor-grab { cursor: grab; } @@ -672,6 +702,9 @@ .grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } .grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); } @@ -702,6 +735,9 @@ .justify-end { justify-content: flex-end; } + .gap-0\.5 { + gap: calc(var(--spacing) * 0.5); + } .gap-1 { gap: calc(var(--spacing) * 1); } @@ -723,6 +759,13 @@ .gap-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 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -737,6 +780,13 @@ 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 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -765,6 +815,13 @@ 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 { column-gap: calc(var(--spacing) * 2); } @@ -854,6 +911,9 @@ .rounded-lg { border-radius: var(--radius-lg); } + .rounded-md { + border-radius: var(--radius-md); + } .rounded-xl { border-radius: var(--radius-xl); } @@ -999,6 +1059,12 @@ .bg-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 { background-color: var(--green); @supports (color: color-mix(in lab, red, red)) { @@ -1017,6 +1083,18 @@ .bg-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 { background-color: var(--peach); } @@ -1026,6 +1104,12 @@ 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 { background-color: var(--peach); @supports (color: color-mix(in lab, red, red)) { @@ -1047,12 +1131,24 @@ 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 { background-color: var(--red); @supports (color: color-mix(in lab, red, red)) { 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 { background-color: var(--sapphire); } @@ -1110,6 +1206,9 @@ .p-8 { padding: calc(var(--spacing) * 8); } + .px-1 { + padding-inline: calc(var(--spacing) * 1); + } .px-1\.5 { padding-inline: calc(var(--spacing) * 1.5); } @@ -1143,12 +1242,18 @@ .py-2 { padding-block: calc(var(--spacing) * 2); } + .py-2\.5 { + padding-block: calc(var(--spacing) * 2.5); + } .py-3 { padding-block: calc(var(--spacing) * 3); } .py-4 { padding-block: calc(var(--spacing) * 4); } + .py-5 { + padding-block: calc(var(--spacing) * 5); + } .py-6 { padding-block: calc(var(--spacing) * 6); } @@ -1173,6 +1278,9 @@ .pr-2 { padding-right: calc(var(--spacing) * 2); } + .pr-4 { + padding-right: calc(var(--spacing) * 4); + } .pr-10 { padding-right: calc(var(--spacing) * 10); } @@ -1188,6 +1296,9 @@ .pl-3 { padding-left: calc(var(--spacing) * 3); } + .pl-4 { + padding-left: calc(var(--spacing) * 4); + } .text-center { text-align: center; } @@ -1212,6 +1323,10 @@ font-size: var(--text-4xl); 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 { font-size: var(--text-9xl); line-height: var(--tw-leading, var(--text-9xl--line-height)); @@ -1299,6 +1414,9 @@ .text-mantle { color: var(--mantle); } + .text-mauve { + color: var(--mauve); + } .text-overlay0 { color: var(--overlay0); } @@ -1320,6 +1438,9 @@ color: color-mix(in oklab, var(--red) 80%, transparent); } } + .text-sky { + color: var(--sky); + } .text-subtext0 { color: var(--subtext0); } @@ -1365,6 +1486,9 @@ .italic { font-style: italic; } + .underline { + text-decoration-line: underline; + } .placeholder-subtext0 { &::placeholder { color: var(--subtext0); @@ -1519,6 +1643,12 @@ 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 { @media (hover: hover) { @@ -2001,6 +2131,11 @@ width: calc(var(--spacing) * 10); } } + .sm\:w-36 { + @media (width >= 40rem) { + width: calc(var(--spacing) * 36); + } + } .sm\:w-auto { @media (width >= 40rem) { width: auto; @@ -2073,6 +2208,16 @@ 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 { @media (width >= 40rem) { padding: calc(var(--spacing) * 6); @@ -2103,12 +2248,30 @@ 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 { @media (width >= 40rem) { font-size: var(--text-4xl); 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 { @media (width >= 48rem) { grid-column: span 2 / span 2; @@ -2174,9 +2337,9 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } - .lg\:grid-cols-6 { + .lg\:flex-row { @media (width >= 64rem) { - grid-template-columns: repeat(6, minmax(0, 1fr)); + flex-direction: row; } } .lg\:items-end { @@ -2184,6 +2347,11 @@ align-items: flex-end; } } + .lg\:items-start { + @media (width >= 64rem) { + align-items: flex-start; + } + } .lg\:justify-between { @media (width >= 64rem) { justify-content: space-between; diff --git a/internal/embedfs/web/js/sortable-table.js b/internal/embedfs/web/js/sortable-table.js new file mode 100644 index 0000000..f951d2d --- /dev/null +++ b/internal/embedfs/web/js/sortable-table.js @@ -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)); + }, + }; +} diff --git a/internal/handlers/fixture_detail.go b/internal/handlers/fixture_detail.go index e662696..c6398d9 100644 --- a/internal/handlers/fixture_detail.go +++ b/internal/handlers/fixture_detail.go @@ -47,6 +47,7 @@ func FixtureDetailPage( var rosters map[string][]*db.PlayerWithPlayStatus var nominatedFreeAgents []*db.FixtureFreeAgent var availableFreeAgents []*db.SeasonLeagueFreeAgent + var previewData *db.MatchPreviewData if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error @@ -94,6 +95,15 @@ func FixtureDetailPage( 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 }); !ok { return @@ -102,6 +112,7 @@ func FixtureDetailPage( renderSafely(seasonsview.FixtureDetailPage( fixture, currentSchedule, history, canSchedule, userTeamID, result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents, + previewData, ), s, r, w) }) } diff --git a/internal/handlers/player_link_slapid.go b/internal/handlers/player_link_slapid.go new file mode 100644 index 0000000..0f181b5 --- /dev/null +++ b/internal/handlers/player_link_slapid.go @@ -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) + }) +} diff --git a/internal/handlers/player_stats_filter.go b/internal/handlers/player_stats_filter.go new file mode 100644 index 0000000..531b6b5 --- /dev/null +++ b/internal/handlers/player_stats_filter.go @@ -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= +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) + }) +} diff --git a/internal/handlers/player_view.go b/internal/handlers/player_view.go new file mode 100644 index 0000000..a8a15aa --- /dev/null +++ b/internal/handlers/player_view.go @@ -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) + } + }) +} diff --git a/internal/handlers/register.go b/internal/handlers/register.go index f787f05..61bbb24 100644 --- a/internal/handlers/register.go +++ b/internal/handlers/register.go @@ -73,7 +73,7 @@ func Register( if err != nil { 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 { return false, errors.Wrap(err, "connectSlapID") } @@ -123,11 +123,11 @@ func registerUser(ctx context.Context, tx bun.Tx, 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, ) 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) if err != nil { return errors.Wrap(err, "discord.NewOAuthSession") diff --git a/internal/handlers/season_league_stats.go b/internal/handlers/season_league_stats.go index 96a64e0..18add75 100644 --- a/internal/handlers/season_league_stats.go +++ b/internal/handlers/season_league_stats.go @@ -22,6 +22,10 @@ func SeasonLeagueStatsPage( leagueStr := r.PathValue("league_short_name") 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) { var err error @@ -33,15 +37,36 @@ func SeasonLeagueStatsPage( } 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 }); !ok { return } 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 { - renderSafely(seasonsview.SeasonLeagueStats(), s, r, w) + renderSafely(seasonsview.SeasonLeagueStats(sl.Season, sl.League, topGoals, topAssists, topSaves, allStats), s, r, w) } }) } diff --git a/internal/handlers/season_league_table.go b/internal/handlers/season_league_table.go index c870109..a9fb7be 100644 --- a/internal/handlers/season_league_table.go +++ b/internal/handlers/season_league_table.go @@ -62,7 +62,7 @@ func SeasonLeagueTablePage( if r.Method == "GET" { renderSafely(seasonsview.SeasonLeagueTablePage(season, league, leaderboard), s, r, w) } else { - renderSafely(seasonsview.SeasonLeagueTable(leaderboard), s, r, w) + renderSafely(seasonsview.SeasonLeagueTable(season, league, leaderboard), s, r, w) } }) } diff --git a/internal/handlers/season_league_team_detail.go b/internal/handlers/season_league_team_detail.go index 5d55c03..48e0bb3 100644 --- a/internal/handlers/season_league_team_detail.go +++ b/internal/handlers/season_league_team_detail.go @@ -35,6 +35,7 @@ func SeasonLeagueTeamDetailPage( var scheduleMap map[int]*db.FixtureSchedule var resultMap map[int]*db.FixtureResult var playerStats []*db.AggregatedPlayerStats + var leaderboard []*db.LeaderboardEntry if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error @@ -72,12 +73,51 @@ func SeasonLeagueTeamDetailPage( 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 }); !ok { return } - record := db.ComputeTeamRecord(teamID, fixtures, resultMap) - renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available, scheduleMap, resultMap, record, playerStats), s, r, w) + // Find this team's position and record from the leaderboard + 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) }) } diff --git a/internal/handlers/team_detail.go b/internal/handlers/team_detail.go new file mode 100644 index 0000000..7b607db --- /dev/null +++ b/internal/handlers/team_detail.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/throw" + teamsview "git.haelnorr.com/h/oslstats/internal/view/teamsview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// TeamDetailPage renders the global team detail page showing cross-season stats +func TeamDetailPage( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + teamIDStr := r.PathValue("team_id") + + teamID, err := strconv.Atoi(teamIDStr) + if err != nil { + throw.NotFound(s, w, r, r.URL.Path) + return + } + + var team *db.Team + var seasonInfos []*db.TeamSeasonInfo + var playerStats []*db.TeamAllTimePlayerStats + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + team, err = db.GetTeam(ctx, tx, teamID) + if err != nil { + if db.IsBadRequest(err) { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return false, errors.Wrap(err, "db.GetTeam") + } + + seasonInfos, err = db.GetTeamSeasonParticipation(ctx, tx, teamID) + if err != nil { + return false, errors.Wrap(err, "db.GetTeamSeasonParticipation") + } + + playerStats, err = db.GetTeamAllTimePlayerStats(ctx, tx, teamID) + if err != nil { + return false, errors.Wrap(err, "db.GetTeamAllTimePlayerStats") + } + + return true, nil + }); !ok { + return + } + + activeTab := r.URL.Query().Get("tab") + if activeTab != "seasons" && activeTab != "stats" { + activeTab = "seasons" + } + + renderSafely(teamsview.DetailPage(team, seasonInfos, playerStats, activeTab), s, r, w) + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 38fda14..80978fc 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -64,6 +64,11 @@ func addRoutes( Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, Handler: auth.LoginReq(handlers.Logout(s, auth, conn, discordAPI)), }, + { + Path: "/profile", + Method: hws.MethodGET, + Handler: auth.LoginReq(handlers.ProfileRedirect(s)), + }, } 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{ { Path: "/teams", @@ -316,6 +354,11 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.TeamsManagePlayers)(handlers.ManageTeamRoster(s, conn)), }, + { + Path: "/teams/{team_id}", + Method: hws.MethodGET, + Handler: handlers.TeamDetailPage(s, conn), + }, } htmxRoutes := []hws.Route{ @@ -463,6 +506,7 @@ func addRoutes( routes = append(routes, leagueRoutes...) routes = append(routes, fixturesRoutes...) routes = append(routes, teamRoutes...) + routes = append(routes, playerRoutes...) // Register the routes with the server err := s.AddRoutes(routes...) diff --git a/internal/view/component/links/links.templ b/internal/view/component/links/links.templ new file mode 100644 index 0000000..ed6245e --- /dev/null +++ b/internal/view/component/links/links.templ @@ -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) { + + { player.DisplayName() } + +} + +// 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) { + + { playerName } + +} + +// 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) { + + if team.Color != "" { + + } + { team.Name } + +} + +// 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) { + + { team.Name } + +} diff --git a/internal/view/playersview/player_page.templ b/internal/view/playersview/player_page.templ new file mode 100644 index 0000000..9024d96 --- /dev/null +++ b/internal/view/playersview/player_page.templ @@ -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") { +
+
+ +
+
+
+
+

{ player.DisplayName() }

+ if isOwner { + + Your Profile + + } +
+
+ if player.SlapID != nil { + + Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) } + + } +
+
+
+
+ + if player.SlapID == nil && isOwner { +
+ @SlapIDSection(player, isOwner) +
+ } + + + +
+ { children... } +
+
+
+ + } +} + +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) + }} +
  • + + { label } + +
  • +} + +// 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) + } +} diff --git a/internal/view/playersview/player_seasons_tab.templ b/internal/view/playersview/player_seasons_tab.templ new file mode 100644 index 0000000..4be3983 --- /dev/null +++ b/internal/view/playersview/player_seasons_tab.templ @@ -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 { +
    +

    No season history yet.

    +

    This player has not participated in any seasons.

    +
    + } else { +
    +
    + + + + + + + + + + + for _, info := range seasonInfos { + + + + + + + } + +
    SeasonLeagueTeamRole
    + + { info.Season.Name } + + + { info.League.Name } + + +
    + if info.Team.Color != "" { +
    + } + { info.Team.Name } +
    +
    +
    + if info.IsManager { + + Manager + + } else { + Player + } +
    +
    +
    + } +} diff --git a/internal/view/playersview/player_stats_tab.templ b/internal/view/playersview/player_stats_tab.templ new file mode 100644 index 0000000..021cd11 --- /dev/null +++ b/internal/view/playersview/player_stats_tab.templ @@ -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) { +
    + +
    + +
    + + +
    + +
    + + +
    +
    + +
    + if activeFilter == "" { + Showing All-Time stats + } else if activeFilter == "season" { + Showing stats for season: + + { getSeasonName(seasons, activeFilterID) } + + } else if activeFilter == "team" { + Showing stats for team: + + { getTeamName(teams, activeFilterID) } + + } +
    + + @playerStatsGrid(stats) +
    +} + +templ playerStatsGrid(stats *db.PlayerAllTimeStats) { +
    + @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") +
    +} + +templ statCard(label string, value string, colorClass string) { +
    +

    { label }

    +

    { value }

    +
    +} + +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" +} diff --git a/internal/view/playersview/player_teams_tab.templ b/internal/view/playersview/player_teams_tab.templ new file mode 100644 index 0000000..7182b8a --- /dev/null +++ b/internal/view/playersview/player_teams_tab.templ @@ -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 { +
    +

    No team history yet.

    +

    This player has not been on any teams.

    +
    + } else { +
    +
    + + + + + + + + + for _, info := range teamInfos { + + + + + } + +
    TeamSeasons Played
    + +
    + if info.Team.Color != "" { +
    + } + { info.Team.Name } +
    +
    +
    + { fmt.Sprint(info.SeasonsCount) } +
    +
    +
    + } +} diff --git a/internal/view/playersview/slap_id_section.templ b/internal/view/playersview/slap_id_section.templ new file mode 100644 index 0000000..1be519b --- /dev/null +++ b/internal/view/playersview/slap_id_section.templ @@ -0,0 +1,52 @@ +package playersview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" + +templ SlapIDSection(player *db.Player, isOwner bool) { +
    + if player.SlapID == nil && isOwner { + @slapIDLinkPrompt(player) + } +
    +} + +templ slapIDLinkPrompt(player *db.Player) { +
    +
    + + + +
    +

    Slapshot ID Not Linked

    +

    + 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. +

    +

    + Need help linking Steam to Discord? + + Follow this guide + +

    + +
    +
    +
    +} diff --git a/internal/view/seasonsview/fixture_detail.templ b/internal/view/seasonsview/fixture_detail.templ index cc33e2e..4918db7 100644 --- a/internal/view/seasonsview/fixture_detail.templ +++ b/internal/view/seasonsview/fixture_detail.templ @@ -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/contexts" import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" import "fmt" import "sort" import "strings" @@ -19,6 +20,7 @@ templ FixtureDetailPage( activeTab string, nominatedFreeAgents []*db.FixtureFreeAgent, availableFreeAgents []*db.SeasonLeagueFreeAgent, + previewData *db.MatchPreviewData, ) { {{ permCache := contexts.Permissions(ctx) @@ -32,6 +34,14 @@ templ FixtureDetailPage( if isFinalized && activeTab == "schedule" { 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)) {
    @@ -70,19 +80,26 @@ templ FixtureDetailPage(
    - - if !isFinalized { - if activeTab == "overview" { @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" { @fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID) } @@ -147,8 +164,8 @@ templ fixtureOverviewTab( }
    - @fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result) - @fixtureTeamSection(fixture.AwayTeam, rosters["away"], "away", result) + @fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result, fixture.Season, fixture.League) + @fixtureTeamSection(fixture.AwayTeam, rosters["away"], "away", result, fixture.Season, fixture.League)
    } @@ -603,7 +620,7 @@ templ forfeitModal(fixture *db.Fixture) { } -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 var playing []*db.PlayerWithPlayStatus @@ -640,8 +657,8 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side }}
    -

    - { team.Name } +

    + @links.TeamNameLinkInSeason(team, season, league)

    if team.Color != "" { Player + PP SC G A @@ -674,9 +692,9 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side for _, p := range playing { - + - { p.Player.DisplayName() } + @links.PlayerLink(p.Player) if p.IsManager { ★ @@ -690,6 +708,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side if p.Stats != nil { + { intPtrStr(p.Stats.PeriodsPlayed) } { intPtrStr(p.Stats.Score) } { intPtrStr(p.Stats.Goals) } { intPtrStr(p.Stats.Assists) } @@ -698,7 +717,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side { intPtrStr(p.Stats.Blocks) } { intPtrStr(p.Stats.Passes) } } else { - — + — } } @@ -713,7 +732,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side for _, p := range bench {
    - { p.Player.DisplayName() } + @links.PlayerLink(p.Player) if p.IsManager { @@ -735,8 +754,8 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
    for _, p := range playing {
    - - { p.Player.DisplayName() } + + @links.PlayerLink(p.Player) if p.IsManager { @@ -758,7 +777,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side for _, p := range bench {
    - { p.Player.DisplayName() } + @links.PlayerLink(p.Player) if p.IsManager { @@ -838,7 +857,9 @@ templ fixtureFreeAgentSection( for _, n := range homeNominated {
    - { n.Player.DisplayName() } + + @links.PlayerLink(n.Player) + FA @@ -873,7 +894,9 @@ templ fixtureFreeAgentSection( for _, n := range awayNominated {
    - { n.Player.DisplayName() } + + @links.PlayerLink(n.Player) + FA diff --git a/internal/view/seasonsview/fixture_match_analysis.templ b/internal/view/seasonsview/fixture_match_analysis.templ new file mode 100644 index 0000000..854256c --- /dev/null +++ b/internal/view/seasonsview/fixture_match_analysis.templ @@ -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, +) { +
    + + @analysisScoreHeader(fixture, result) + + + @analysisTeamStatsComparison(fixture, rosters) + + + @analysisTopPerformers(fixture, rosters) + + + if preview != nil { + @analysisStandingsContext(fixture, preview) + } +
    +} + +// 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 + }} +
    +
    +

    Final Score

    +
    +
    + if isForfeit { + @analysisForfeitDisplay(fixture, result) + } else { +
    + +
    + if fixture.HomeTeam.Color != "" { +
    + } +

    + @links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League) +

    + + { fmt.Sprint(result.HomeScore) } + + if homeWon { + Winner + } +
    + +
    + + if isOT { + OT + } +
    + +
    + if fixture.AwayTeam.Color != "" { +
    + } +

    + @links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League) +

    + + { fmt.Sprint(result.AwayScore) } + + if awayWon { + Winner + } +
    +
    + } +
    +
    +} + +// 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 + } + } + }} +
    + if isMutualForfeit { + MUTUAL FORFEIT +

    Both teams receive an overtime loss

    + } else if isOutrightForfeit { + FORFEIT +

    + { forfeitTeamName } forfeited — { winnerTeamName } wins +

    + } + if result.ForfeitReason != nil && *result.ForfeitReason != "" { +
    +

    Reason

    +

    { *result.ForfeitReason }

    +
    + } +
    +} + +// 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"]) + }} +
    +
    +

    Team Statistics

    +
    +
    + +
    +
    +
    + if fixture.HomeTeam.Color != "" { + + } + { fixture.HomeTeam.ShortName } +
    +
    +
    +
    +
    + { fixture.AwayTeam.ShortName } + if fixture.AwayTeam.Color != "" { + + } +
    +
    +
    + +
    + @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, + ) + + {{ + 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, + ) +
    +
    +
    +} + +// 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 { +
    +
    +

    Top Performers

    +
    +
    +
    + +
    +
    + if fixture.HomeTeam.Color != "" { + + } +

    { fixture.HomeTeam.Name }

    +
    +
    + for i, p := range homeTop { + @topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1) + } +
    +
    + +
    +
    + if fixture.AwayTeam.Color != "" { + + } +

    { fixture.AwayTeam.Name }

    +
    +
    + for i, p := range awayTop { + @topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1) + } +
    +
    +
    +
    +
    + } +} + +// 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] + }} +
    + { rankLabel } +
    +
    + + @links.PlayerLink(player) + + if isManager { + + ★ + + } + if isFreeAgent { + + FA + + } +
    +
    + if stats.Score != nil { + { fmt.Sprint(*stats.Score) } SC + } + if stats.Goals != nil { + { fmt.Sprint(*stats.Goals) } G + } + if stats.Assists != nil { + { fmt.Sprint(*stats.Assists) } A + } + if stats.Saves != nil { + { fmt.Sprint(*stats.Saves) } SV + } + if stats.Shots != nil { + { fmt.Sprint(*stats.Shots) } SH + } +
    +
    +
    +} + +// analysisStandingsContext shows how this result fits into the league standings. +templ analysisStandingsContext(fixture *db.Fixture, preview *db.MatchPreviewData) { +
    +
    +

    League Context

    +
    +
    + +
    +
    +
    + if fixture.HomeTeam.Color != "" { + + } + { fixture.HomeTeam.ShortName } +
    +
    +
    +
    +
    + { fixture.AwayTeam.ShortName } + if fixture.AwayTeam.Color != "" { + + } +
    +
    +
    +
    + {{ + 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, + ) + + if len(preview.HomeRecentGames) > 0 || len(preview.AwayRecentGames) > 0 { +
    +
    +
    + for _, g := range preview.HomeRecentGames { + @gameOutcomeIcon(g) + } +
    +
    +
    + Form +
    +
    +
    + for _, g := range preview.AwayRecentGames { + @gameOutcomeIcon(g) + } +
    +
    +
    + } +
    +
    +
    +} diff --git a/internal/view/seasonsview/fixture_match_preview.templ b/internal/view/seasonsview/fixture_match_preview.templ new file mode 100644 index 0000000..7a7e91d --- /dev/null +++ b/internal/view/seasonsview/fixture_match_preview.templ @@ -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, +) { +
    + + @matchPreviewHeader(fixture, preview) + + + @matchPreviewFormGuide(fixture, preview) + + + @matchPreviewRosters(fixture, rosters) +
    +} + +// matchPreviewHeader renders the broadcast-style team comparison with standings. +templ matchPreviewHeader(fixture *db.Fixture, preview *db.MatchPreviewData) { +
    +
    +

    Team Comparison

    +
    +
    + +
    + +
    + if fixture.HomeTeam.Color != "" { +
    + } +

    + @links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League) +

    + { fixture.HomeTeam.ShortName } +
    + +
    + VS +
    + +
    + if fixture.AwayTeam.Color != "" { +
    + } +

    + @links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League) +

    + { fixture.AwayTeam.ShortName } +
    +
    + + {{ + 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.Sprint(preview.HomeRecord.Played), + "Played", + fmt.Sprint(preview.AwayRecord.Played), + false, + false, + ) + + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Wins), + "Wins", + fmt.Sprint(preview.AwayRecord.Wins), + preview.HomeRecord.Wins > preview.AwayRecord.Wins, + preview.AwayRecord.Wins > preview.HomeRecord.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, + ) + + @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, + ) + + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Losses), + "Losses", + fmt.Sprint(preview.AwayRecord.Losses), + preview.HomeRecord.Losses < preview.AwayRecord.Losses, + preview.AwayRecord.Losses < preview.HomeRecord.Losses, + ) + + @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, + ) + + @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, + ) + + {{ + 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, + ) +
    +
    +
    +} + +// 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) { +
    + +
    + + { homeValue } + +
    + +
    + { label } +
    + +
    + + { awayValue } + +
    +
    +} + +// matchPreviewFormGuide renders the recent form section with last 5 game outcome icons. +templ matchPreviewFormGuide(fixture *db.Fixture, preview *db.MatchPreviewData) { +
    +
    +

    Recent Form

    +
    +
    +
    + +
    +
    + if fixture.HomeTeam.Color != "" { + + } +

    { fixture.HomeTeam.Name }

    +
    + if len(preview.HomeRecentGames) == 0 { +

    No recent matches played

    + } else { + +
    + for _, g := range preview.HomeRecentGames { + @gameOutcomeIcon(g) + } +
    + +
    + for i := len(preview.HomeRecentGames) - 1; i >= 0; i-- { + @recentGameRow(preview.HomeRecentGames[i]) + } +
    + } +
    + +
    +
    + if fixture.AwayTeam.Color != "" { + + } +

    { fixture.AwayTeam.Name }

    +
    + if len(preview.AwayRecentGames) == 0 { +

    No recent matches played

    + } else { + +
    + for _, g := range preview.AwayRecentGames { + @gameOutcomeIcon(g) + } +
    + +
    + for i := len(preview.AwayRecentGames) - 1; i >= 0; i-- { + @recentGameRow(preview.AwayRecentGames[i]) + } +
    + } +
    +
    +
    +
    +} + +// 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) + } + } + }} + + { style.label } + +} + +// 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 + } + }} +
    + { style.fullLabel } + vs { opponentName } + if outcome.IsForfeit { + Forfeit + } else if outcome.Score != "" { + { outcome.Score } + } +
    +} + +// 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"] + }} +
    +
    +

    Team Rosters

    +
    +
    +
    + + @previewRosterColumn(fixture.HomeTeam, homePlayers, fixture.Season, fixture.League) + + @previewRosterColumn(fixture.AwayTeam, awayPlayers, fixture.Season, fixture.League) +
    +
    +
    +} + +// 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() + }) + }} +
    + +
    +
    + if team.Color != "" { + + } +

    + @links.TeamNameLinkInSeason(team, season, league) +

    +
    + + { fmt.Sprint(len(players)) } players + +
    + if len(players) == 0 { +

    No players on roster.

    + } else { +
    + + for _, p := range managers { +
    + + ★ + + + @links.PlayerLink(p.Player) + + if p.IsFreeAgent { + + FA + + } +
    + } + + for _, p := range roster { +
    + + @links.PlayerLink(p.Player) + + if p.IsFreeAgent { + + FA + + } +
    + } +
    + } +
    +} diff --git a/internal/view/seasonsview/fixture_review_result.templ b/internal/view/seasonsview/fixture_review_result.templ index 8ac5308..2921621 100644 --- a/internal/view/seasonsview/fixture_review_result.templ +++ b/internal/view/seasonsview/fixture_review_result.templ @@ -2,6 +2,7 @@ package seasonsview 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/component/links" import "fmt" templ FixtureReviewResultPage( @@ -22,7 +23,13 @@ templ FixtureReviewResultPage(

    Review Match Result

    - { fixture.HomeTeam.Name } vs { fixture.AwayTeam.Name } + + @links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League) + + vs + + @links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League) + Round { fmt.Sprint(fixture.Round) } @@ -96,12 +103,16 @@ templ FixtureReviewResultPage(

    -

    { fixture.HomeTeam.Name }

    +

    + @links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League) +

    { fmt.Sprint(result.HomeScore) }

    -

    { fixture.AwayTeam.Name }

    +

    + @links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League) +

    { fmt.Sprint(result.AwayScore) }

    @@ -127,8 +138,8 @@ templ FixtureReviewResultPage(
    - @reviewTeamStats(fixture.HomeTeam, result, "home") - @reviewTeamStats(fixture.AwayTeam, result, "away") + @reviewTeamStats(fixture.HomeTeam, result, "home", fixture.Season, fixture.League) + @reviewTeamStats(fixture.AwayTeam, result, "away", fixture.Season, fixture.League)
    @@ -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 // We'll show the period 3 (final/cumulative) stats @@ -197,7 +208,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) { } else { Away — } - { team.Name } + @links.TeamNameLinkInSeason(team, season, league)
    @@ -205,6 +216,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) { Player + PP G A SV @@ -217,10 +229,12 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) { for _, ps := range finalStats { - + - { ps.Username } - if ps.PlayerID == nil { + if ps.PlayerID != nil { + @links.PlayerLinkFromStats(*ps.PlayerID, ps.Username) + } else { + { ps.Username } ? } if ps.Stats.IsFreeAgent { @@ -230,6 +244,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) { } + { intPtrStr(ps.Stats.PeriodsPlayed) } { intPtrStr(ps.Stats.Goals) } { intPtrStr(ps.Stats.Assists) } { intPtrStr(ps.Stats.Saves) } @@ -241,7 +256,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) { } if len(finalStats) == 0 { - + No player stats recorded @@ -258,3 +273,20 @@ func intPtrStr(v *int) string { } 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) +} diff --git a/internal/view/seasonsview/season_league_free_agents.templ b/internal/view/seasonsview/season_league_free_agents.templ index 7ff354b..18271e1 100644 --- a/internal/view/seasonsview/season_league_free_agents.templ +++ b/internal/view/seasonsview/season_league_free_agents.templ @@ -3,6 +3,7 @@ package seasonsview import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/permissions" import "git.haelnorr.com/h/oslstats/internal/contexts" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" import "fmt" 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 [] Player - Registered By if canRemove { Actions } @@ -53,19 +53,14 @@ templ SeasonLeagueFreeAgents(season *db.Season, league *db.League, freeAgents [] for _, fa := range freeAgents { - + - { fa.Player.DisplayName() } + @links.PlayerLink(fa.Player) FREE AGENT - - if fa.RegisteredBy != nil { - { fa.RegisteredBy.Username } - } - if canRemove {
    -

    Coming Soon...

    +templ SeasonLeagueStats( + season *db.Season, + league *db.League, + topGoals []*db.LeagueTopGoalScorer, + topAssists []*db.LeagueTopAssister, + topSaves []*db.LeagueTopSaver, + allStats []*db.LeaguePlayerStats, +) { + + if len(topGoals) == 0 && len(topAssists) == 0 && len(topSaves) == 0 && len(allStats) == 0 { +
    +

    No stats available yet.

    +

    Player statistics will appear here once games are finalized.

    +
    + } else { +
    + + if len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 { +
    +

    Trophy Leaders

    + +
    + +
    + @topGoalScorersTable(season, league, topGoals) + @topAssistersTable(season, league, topAssists) +
    + + @topSaversTable(season, league, topSaves) +
    +
    + } + + if len(allStats) > 0 { +
    +

    All Stats

    + @allStatsTable(season, league, allStats) +
    + } +
    + } +} + +templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.LeagueTopGoalScorer) { +
    +
    +

    + Top Goal Scorers +

    +
    + +
    + Sort: + G ↓ + PP ↑ + SH ↑ +
    + if len(goals) == 0 { +
    +

    No goal data available yet.

    +
    + } else { + + + + + + + + + + + + + for i, gs := range goals { + + + + + + + + + } + +
    #PlayerTeamGPPSH
    + { fmt.Sprint(i + 1) } + + @links.PlayerLinkFromStats(gs.PlayerID, gs.PlayerName) + + @teamColorName(gs.TeamID, gs.TeamName, gs.TeamColor, season, league) + { fmt.Sprint(gs.Goals) }{ fmt.Sprint(gs.PeriodsPlayed) }{ fmt.Sprint(gs.Shots) }
    + }
    } + +templ topAssistersTable(season *db.Season, league *db.League, assists []*db.LeagueTopAssister) { +
    +
    +

    + Top Assisters +

    +
    + +
    + Sort: + A ↓ + PP ↑ + PA ↓ +
    + if len(assists) == 0 { +
    +

    No assist data available yet.

    +
    + } else { + + + + + + + + + + + + + for i, as := range assists { + + + + + + + + + } + +
    #PlayerTeamAPPPA
    + { fmt.Sprint(i + 1) } + + @links.PlayerLinkFromStats(as.PlayerID, as.PlayerName) + + @teamColorName(as.TeamID, as.TeamName, as.TeamColor, season, league) + { fmt.Sprint(as.Assists) }{ fmt.Sprint(as.PeriodsPlayed) }{ fmt.Sprint(as.PrimaryAssists) }
    + } +
    +} + +templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTopSaver) { +
    +
    +

    + Top Saves +

    +
    + +
    + Sort: + SV ↓ + PP ↑ + BLK ↓ +
    + if len(saves) == 0 { +
    +

    No save data available yet.

    +
    + } else { + + + + + + + + + + + + + for i, sv := range saves { + + + + + + + + + } + +
    #PlayerTeamSVPPBLK
    + { fmt.Sprint(i + 1) } + + @links.PlayerLinkFromStats(sv.PlayerID, sv.PlayerName) + + @teamColorName(sv.TeamID, sv.TeamName, sv.TeamColor, season, league) + { fmt.Sprint(sv.Saves) }{ fmt.Sprint(sv.PeriodsPlayed) }{ fmt.Sprint(sv.Blocks) }
    + } +
    +} + +templ allStatsTable(season *db.Season, league *db.League, allStats []*db.LeaguePlayerStats) { +
    +
    + + + + + + @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") + + + + for _, ps := range allStats { + + + + + + + + + + + + + + + + } + +
    PlayerTeam
    + @links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName) + + @teamColorName(ps.TeamID, ps.TeamName, ps.TeamColor, season, league) + { fmt.Sprint(ps.GamesPlayed) }{ fmt.Sprint(ps.PeriodsPlayed) }{ fmt.Sprint(ps.Score) }{ fmt.Sprint(ps.Goals) }{ fmt.Sprint(ps.Assists) }{ fmt.Sprint(ps.PrimaryAssists) }{ fmt.Sprint(ps.SecondaryAssists) }{ fmt.Sprint(ps.Saves) }{ fmt.Sprint(ps.Shots) }{ fmt.Sprint(ps.Blocks) }{ fmt.Sprint(ps.Passes) }
    +
    +
    +} + +templ sortableCol(field string, label string, title string) { + + + { label } + + + +} + +templ teamColorName(teamID int, teamName string, teamColor string, season *db.Season, league *db.League) { + if teamID > 0 && teamName != "" { + + if teamColor != "" { + + } + { teamName } + + } else { + + } +} diff --git a/internal/view/seasonsview/season_league_table.templ b/internal/view/seasonsview/season_league_table.templ index ae80916..560c507 100644 --- a/internal/view/seasonsview/season_league_table.templ +++ b/internal/view/seasonsview/season_league_table.templ @@ -1,15 +1,16 @@ package seasonsview import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" import "fmt" templ SeasonLeagueTablePage(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) { @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 {

    No teams in this league yet.

    @@ -43,7 +44,7 @@ templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) { for _, entry := range leaderboard { - @leaderboardRow(entry) + @leaderboardRow(entry, season, league) } @@ -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 goalDiff := r.GoalsFor - r.GoalsAgainst @@ -68,15 +69,7 @@ templ leaderboardRow(entry *db.LeaderboardEntry) { { fmt.Sprint(entry.Position) } -
    - if entry.Team.Color != "" { - - } - { entry.Team.Name } -
    + @links.TeamLinkInSeason(entry.Team, season, league) { fmt.Sprint(r.Played) } diff --git a/internal/view/seasonsview/season_league_team_detail.templ b/internal/view/seasonsview/season_league_team_detail.templ index ad1dedf..709ffa4 100644 --- a/internal/view/seasonsview/season_league_team_detail.templ +++ b/internal/view/seasonsview/season_league_team_detail.templ @@ -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/contexts" import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" import "fmt" import "sort" 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 season := twr.Season @@ -42,25 +43,68 @@ templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture,
    - - Back to Teams - +
    - + {{ + // 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] + } + }}
    + + @teamRecordCard(record, position, totalTeams) + + @teamResultsSection(twr.Team, recentResults, resultMap) + @TeamRosterSection(twr, available) - @teamFixturesPane(twr.Team, fixtures, scheduleMap, resultMap) + + @teamUpcomingSection(twr.Team, upcoming, scheduleMap)
    - +
    - @teamStatsSection(record, playerStats) + @playerStatsSection(playerStats)
    @@ -111,7 +155,9 @@ templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) {
    if twr.Manager != nil {
    - { twr.Manager.Name } + + @links.PlayerLink(twr.Manager) + ★ Manager @@ -119,7 +165,7 @@ templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) { } for _, player := range rosterPlayers {
    - { player.Name } + @links.PlayerLink(player)
    }
    @@ -396,68 +442,45 @@ templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPl } -templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) { - {{ - // Split fixtures into upcoming and completed - var upcoming []*db.Fixture - var completed []*db.Fixture - for _, f := range fixtures { - if _, hasResult := resultMap[f.ID]; hasResult { - completed = append(completed, f) - } else { - upcoming = append(upcoming, f) - } - } - // Sort completed by scheduled time descending (most recent first) - sort.Slice(completed, func(i, j int) bool { - ti := time.Time{} - tj := time.Time{} - if si, ok := scheduleMap[completed[i].ID]; ok && si.ScheduledTime != nil { - ti = *si.ScheduledTime - } - if sj, ok := scheduleMap[completed[j].ID]; ok && sj.ScheduledTime != nil { - tj = *sj.ScheduledTime - } - return ti.After(tj) - }) - // Limit to 5 most recent results - recentResults := completed - if len(recentResults) > 5 { - recentResults = recentResults[:5] - } - }} -
    - -
    -

    Results

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

    No results yet.

    -

    Match results will appear here once games are played.

    -
    - } else { -
    - for _, fixture := range recentResults { - @teamResultRow(team, fixture, resultMap) - } -
    - } +templ teamResultsSection(team *db.Team, recentResults []*db.Fixture, resultMap map[int]*db.FixtureResult) { +
    +
    +

    + Results + (last 5) +

    - -
    -

    Upcoming

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

    No upcoming fixtures.

    -
    - } else { -
    - for _, fixture := range upcoming { - @teamFixtureRow(team, fixture, scheduleMap) - } -
    - } + if len(recentResults) == 0 { +
    +

    No results yet.

    +

    Match results will appear here once games are played.

    +
    + } else { +
    + for _, fixture := range recentResults { + @teamResultRow(team, fixture, resultMap) + } +
    + } +
    +} + +templ teamUpcomingSection(team *db.Team, upcoming []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) { +
    +
    +

    Upcoming

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

    No upcoming fixtures.

    +
    + } else { +
    + for _, fixture := range upcoming { + @teamFixtureRow(team, fixture, scheduleMap) + } +
    + }
    } @@ -586,65 +609,98 @@ templ teamResultRow(team *db.Team, fixture *db.Fixture, resultMap map[int]*db.Fi } -templ teamStatsSection(record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) { +templ teamRecordCard(record *db.TeamRecord, position int, totalTeams int) {
    -
    -

    Stats

    +
    +

    Standing

    if record.Played == 0 {
    -

    No stats yet.

    -

    Team statistics will appear here once games are played.

    +

    No games played yet.

    } else { - -
    -
    +
    + +
    +
    + { ordinal(position) } +
    +

    Position

    +

    of { fmt.Sprint(totalTeams) } teams

    +
    +
    +
    +

    Points

    +

    { fmt.Sprint(record.Points) }

    +
    +
    + +
    + @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") +
    + +
    @statCell("Played", fmt.Sprint(record.Played), "") - @statCell("Record", fmt.Sprintf("%d-%d-%d", record.Wins, record.Losses, record.Draws), "") - @statCell("Wins", fmt.Sprint(record.Wins), "text-green") - @statCell("Losses", fmt.Sprint(record.Losses), "text-red") @statCell("GF", fmt.Sprint(record.GoalsFor), "") @statCell("GA", fmt.Sprint(record.GoalsAgainst), "")
    - - if len(playerStats) > 0 { -
    -
    - - - - - - - - - - - - + } + +} + +templ playerStatsSection(playerStats []*db.AggregatedPlayerStats) { +
    +
    +

    Player Stats

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

    No player stats yet.

    +

    Player statistics will appear here once games are played.

    +
    + } else { +
    +
    +
    PlayerGPSCGASVSHBLPA
    + + + + + + + + + + + + + + + + for _, ps := range playerStats { + + + + + + + + + + + - - - for _, ps := range playerStats { - - - - - - - - - - - - } - -
    PlayerGPPPSCGASVSHBLPA
    + @links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName) + { fmt.Sprint(ps.GamesPlayed) }{ fmt.Sprint(ps.PeriodsPlayed) }{ fmt.Sprint(ps.Score) }{ fmt.Sprint(ps.Goals) }{ fmt.Sprint(ps.Assists) }{ fmt.Sprint(ps.Saves) }{ fmt.Sprint(ps.Shots) }{ fmt.Sprint(ps.Blocks) }{ fmt.Sprint(ps.Passes) }
    { ps.PlayerName }{ fmt.Sprint(ps.GamesPlayed) }{ fmt.Sprint(ps.Score) }{ fmt.Sprint(ps.Goals) }{ fmt.Sprint(ps.Assists) }{ fmt.Sprint(ps.Saves) }{ fmt.Sprint(ps.Shots) }{ fmt.Sprint(ps.Blocks) }{ fmt.Sprint(ps.Passes) }
    -
    + } + +
    - } +
    }
    } diff --git a/internal/view/teamsview/detail_page.templ b/internal/view/teamsview/detail_page.templ new file mode 100644 index 0000000..937bb26 --- /dev/null +++ b/internal/view/teamsview/detail_page.templ @@ -0,0 +1,81 @@ +package teamsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "fmt" + +templ DetailPage(team *db.Team, seasonInfos []*db.TeamSeasonInfo, playerStats []*db.TeamAllTimePlayerStats, activeTab string) { + @baseview.Layout(team.Name) { +
    +
    + +
    +
    +
    + if team.Color != "" { +
    + } +
    +

    { team.Name }

    +
    + + { team.ShortName } + + + { team.AltShortName } + +
    +
    +
    + + Back to Teams + +
    +
    + + +
    + +
    + if activeTab == "seasons" { + @TeamDetailSeasons(team, seasonInfos) + } else if activeTab == "stats" { + @TeamDetailPlayerStats(playerStats) + } +
    +
    + } +} + +templ teamDetailTab(section string, label string, activeTab string, team *db.Team) { + {{ + isActive := section == activeTab + baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2" + activeClasses := "border-blue text-blue font-semibold" + inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2" + url := fmt.Sprintf("/teams/%d", team.ID) + if section != "seasons" { + url = fmt.Sprintf("/teams/%d?tab=%s", team.ID, section) + } + }} +
  • + + { label } + +
  • +} diff --git a/internal/view/teamsview/detail_player_stats.templ b/internal/view/teamsview/detail_player_stats.templ new file mode 100644 index 0000000..ad7301f --- /dev/null +++ b/internal/view/teamsview/detail_player_stats.templ @@ -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 { +
    +

    No player stats yet.

    +

    Player statistics will appear here once games are played.

    +
    + } else { +
    + +
    + + + +
    + +
    + @playerStatsTable(playerStats, "goals") +
    + +
    + @playerStatsTable(playerStats, "assists") +
    + +
    + @playerStatsTable(playerStats, "saves") +
    +
    + } +} + +templ playerStatsTable(playerStats []*db.TeamAllTimePlayerStats, statType string) { + {{ + // Make a copy so sorting doesn't affect other views + sorted := make([]*db.TeamAllTimePlayerStats, len(playerStats)) + copy(sorted, playerStats) + + switch statType { + case "goals": + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Goals > sorted[j].Goals + }) + case "assists": + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Assists > sorted[j].Assists + }) + case "saves": + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Saves > sorted[j].Saves + }) + } + + statLabel := "Goals" + statShort := "G" + if statType == "assists" { + statLabel = "Assists" + statShort = "A" + } else if statType == "saves" { + statLabel = "Saves" + statShort = "SV" + } + _ = statLabel + }} +
    +
    + + + + + + + + + + + + for i, ps := range sorted { + + + + + + if statType == "goals" { + + } else if statType == "assists" { + + } else { + + } + + } + +
    #PlayerSZNPP{ statShort }
    + { fmt.Sprint(i + 1) } + + @links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName) + { fmt.Sprint(ps.SeasonsPlayed) }{ fmt.Sprint(ps.PeriodsPlayed) }{ fmt.Sprint(ps.Goals) }{ fmt.Sprint(ps.Assists) }{ fmt.Sprint(ps.Saves) }
    +
    +
    +} diff --git a/internal/view/teamsview/detail_seasons.templ b/internal/view/teamsview/detail_seasons.templ new file mode 100644 index 0000000..cfb5eeb --- /dev/null +++ b/internal/view/teamsview/detail_seasons.templ @@ -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 { +
    +

    No season history yet.

    +

    This team has not participated in any seasons.

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

    { info.Season.Name }

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

    { fmt.Sprint(info.Record.Points) }

    +
    +
    + +
    +
    +

    W

    +

    { fmt.Sprint(info.Record.Wins) }

    +
    +
    +

    OTW

    +

    { fmt.Sprint(info.Record.OvertimeWins) }

    +
    +
    +

    OTL

    +

    { fmt.Sprint(info.Record.OvertimeLosses) }

    +
    +
    +

    L

    +

    { fmt.Sprint(info.Record.Losses) }

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