Merge branch 'stats' into development
This commit is contained in:
@@ -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
|
||||
|
||||
254
internal/db/match_preview.go
Normal file
254
internal/db/match_preview.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// Game outcome type constants.
|
||||
const (
|
||||
OutcomeWin = "W"
|
||||
OutcomeLoss = "L"
|
||||
OutcomeOTWin = "OTW"
|
||||
OutcomeOTLoss = "OTL"
|
||||
OutcomeDraw = "D"
|
||||
OutcomeForfeit = "F"
|
||||
)
|
||||
|
||||
// GameOutcome represents the result of a single game from a team's perspective.
|
||||
type GameOutcome struct {
|
||||
Type string // One of Outcome* constants: "W", "L", "OTW", "OTL", "D", "F"
|
||||
Opponent *Team // The opposing team (may be nil if relation not loaded)
|
||||
Score string // e.g. "3-1" or "" for forfeits
|
||||
IsForfeit bool // Whether this game was decided by forfeit
|
||||
Fixture *Fixture // The fixture itself
|
||||
}
|
||||
|
||||
// MatchPreviewData holds all computed data needed for the match preview tab.
|
||||
type MatchPreviewData struct {
|
||||
HomeRecord *TeamRecord
|
||||
AwayRecord *TeamRecord
|
||||
HomePosition int
|
||||
AwayPosition int
|
||||
TotalTeams int
|
||||
HomeRecentGames []*GameOutcome
|
||||
AwayRecentGames []*GameOutcome
|
||||
}
|
||||
|
||||
// ComputeRecentGames calculates the last N game outcomes for a given team.
|
||||
// Fixtures should be all allocated fixtures for the season+league.
|
||||
// Results should be finalized results mapped by fixture ID.
|
||||
// Schedules should be accepted schedules mapped by fixture ID (for ordering by scheduled time).
|
||||
// The returned outcomes are in chronological order (oldest first, newest last).
|
||||
func ComputeRecentGames(
|
||||
teamID int,
|
||||
fixtures []*Fixture,
|
||||
resultMap map[int]*FixtureResult,
|
||||
scheduleMap map[int]*FixtureSchedule,
|
||||
limit int,
|
||||
) []*GameOutcome {
|
||||
// Collect fixtures involving this team that have finalized results
|
||||
type fixtureWithTime struct {
|
||||
fixture *Fixture
|
||||
result *FixtureResult
|
||||
time time.Time
|
||||
}
|
||||
var played []fixtureWithTime
|
||||
|
||||
for _, f := range fixtures {
|
||||
if f.HomeTeamID != teamID && f.AwayTeamID != teamID {
|
||||
continue
|
||||
}
|
||||
res, ok := resultMap[f.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Use schedule time for ordering, fall back to result creation time
|
||||
t := time.Unix(res.CreatedAt, 0)
|
||||
if scheduleMap != nil {
|
||||
if sched, ok := scheduleMap[f.ID]; ok && sched.ScheduledTime != nil {
|
||||
t = *sched.ScheduledTime
|
||||
}
|
||||
}
|
||||
played = append(played, fixtureWithTime{fixture: f, result: res, time: t})
|
||||
}
|
||||
|
||||
// Sort by time descending (most recent first)
|
||||
sort.Slice(played, func(i, j int) bool {
|
||||
return played[i].time.After(played[j].time)
|
||||
})
|
||||
|
||||
// Take only the most recent N
|
||||
if len(played) > limit {
|
||||
played = played[:limit]
|
||||
}
|
||||
|
||||
// Reverse to chronological order (oldest first)
|
||||
for i, j := 0, len(played)-1; i < j; i, j = i+1, j-1 {
|
||||
played[i], played[j] = played[j], played[i]
|
||||
}
|
||||
|
||||
// Build outcome list
|
||||
outcomes := make([]*GameOutcome, len(played))
|
||||
for i, p := range played {
|
||||
outcomes[i] = buildGameOutcome(teamID, p.fixture, p.result)
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
// buildGameOutcome determines the outcome type for a single game from a team's perspective.
|
||||
// Note: fixtures must have their HomeTeam and AwayTeam relations loaded.
|
||||
func buildGameOutcome(teamID int, fixture *Fixture, result *FixtureResult) *GameOutcome {
|
||||
isHome := fixture.HomeTeamID == teamID
|
||||
var opponent *Team
|
||||
if isHome {
|
||||
opponent = fixture.AwayTeam // may be nil if relation not loaded
|
||||
} else {
|
||||
opponent = fixture.HomeTeam // may be nil if relation not loaded
|
||||
}
|
||||
|
||||
outcome := &GameOutcome{
|
||||
Opponent: opponent,
|
||||
Fixture: fixture,
|
||||
}
|
||||
|
||||
// Handle forfeits
|
||||
if result.IsForfeit {
|
||||
outcome.IsForfeit = true
|
||||
outcome.Type = OutcomeForfeit
|
||||
if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeMutual {
|
||||
outcome.Type = OutcomeOTLoss // mutual forfeit counts as OT loss for both
|
||||
} else if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeOutright {
|
||||
thisSide := "away"
|
||||
if isHome {
|
||||
thisSide = "home"
|
||||
}
|
||||
if result.ForfeitTeam != nil && *result.ForfeitTeam == thisSide {
|
||||
outcome.Type = OutcomeLoss // this team forfeited
|
||||
} else {
|
||||
outcome.Type = OutcomeWin // opponent forfeited
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
// Normal match - build score string from this team's perspective
|
||||
if isHome {
|
||||
outcome.Score = fmt.Sprintf("%d-%d", result.HomeScore, result.AwayScore)
|
||||
} else {
|
||||
outcome.Score = fmt.Sprintf("%d-%d", result.AwayScore, result.HomeScore)
|
||||
}
|
||||
|
||||
won := (isHome && result.Winner == "home") || (!isHome && result.Winner == "away")
|
||||
lost := (isHome && result.Winner == "away") || (!isHome && result.Winner == "home")
|
||||
isOT := strings.EqualFold(result.EndReason, "Overtime")
|
||||
|
||||
switch {
|
||||
case won && isOT:
|
||||
outcome.Type = OutcomeOTWin
|
||||
case won:
|
||||
outcome.Type = OutcomeWin
|
||||
case lost && isOT:
|
||||
outcome.Type = OutcomeOTLoss
|
||||
case lost:
|
||||
outcome.Type = OutcomeLoss
|
||||
default:
|
||||
outcome.Type = OutcomeDraw
|
||||
}
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
// GetTeamsForSeasonLeague returns all teams participating in a given season+league.
|
||||
func GetTeamsForSeasonLeague(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Team, error) {
|
||||
var teams []*Team
|
||||
err := tx.NewSelect().
|
||||
Model(&teams).
|
||||
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
|
||||
Where("tp.season_id = ? AND tp.league_id = ?", seasonID, leagueID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
// ComputeMatchPreview fetches all data needed for the match preview tab:
|
||||
// team standings, positions, and recent game outcomes for both teams.
|
||||
func ComputeMatchPreview(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixture *Fixture,
|
||||
) (*MatchPreviewData, error) {
|
||||
if fixture == nil {
|
||||
return nil, errors.New("fixture cannot be nil")
|
||||
}
|
||||
|
||||
// Get all teams in this season+league
|
||||
allTeams, err := GetTeamsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetTeamsForSeasonLeague")
|
||||
}
|
||||
|
||||
// Get all allocated fixtures for the season+league
|
||||
allFixtures, err := GetAllocatedFixtures(ctx, tx, fixture.SeasonID, fixture.LeagueID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetAllocatedFixtures")
|
||||
}
|
||||
|
||||
// Get finalized results
|
||||
allFixtureIDs := make([]int, len(allFixtures))
|
||||
for i, f := range allFixtures {
|
||||
allFixtureIDs[i] = f.ID
|
||||
}
|
||||
allResultMap, err := GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures")
|
||||
}
|
||||
|
||||
// Get accepted schedules for ordering recent games
|
||||
allScheduleMap, err := GetAcceptedSchedulesForFixtures(ctx, tx, allFixtureIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetAcceptedSchedulesForFixtures")
|
||||
}
|
||||
|
||||
// Compute leaderboard
|
||||
leaderboard := ComputeLeaderboard(allTeams, allFixtures, allResultMap)
|
||||
|
||||
// Extract positions and records for both teams
|
||||
preview := &MatchPreviewData{
|
||||
TotalTeams: len(leaderboard),
|
||||
}
|
||||
for _, entry := range leaderboard {
|
||||
if entry.Team.ID == fixture.HomeTeamID {
|
||||
preview.HomePosition = entry.Position
|
||||
preview.HomeRecord = entry.Record
|
||||
}
|
||||
if entry.Team.ID == fixture.AwayTeamID {
|
||||
preview.AwayPosition = entry.Position
|
||||
preview.AwayRecord = entry.Record
|
||||
}
|
||||
}
|
||||
if preview.HomeRecord == nil {
|
||||
preview.HomeRecord = &TeamRecord{}
|
||||
}
|
||||
if preview.AwayRecord == nil {
|
||||
preview.AwayRecord = &TeamRecord{}
|
||||
}
|
||||
|
||||
// Compute recent games (last 5) for each team
|
||||
preview.HomeRecentGames = ComputeRecentGames(
|
||||
fixture.HomeTeamID, allFixtures, allResultMap, allScheduleMap, 5,
|
||||
)
|
||||
preview.AwayRecentGames = ComputeRecentGames(
|
||||
fixture.AwayTeamID, allFixtures, allResultMap, allScheduleMap, 5,
|
||||
)
|
||||
|
||||
return preview, nil
|
||||
}
|
||||
@@ -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").
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
36
internal/embedfs/web/js/sortable-table.js
Normal file
36
internal/embedfs/web/js/sortable-table.js
Normal file
@@ -0,0 +1,36 @@
|
||||
function sortableTable(initField, initDir) {
|
||||
return {
|
||||
sortField: initField || "score",
|
||||
sortDir: initDir || "desc",
|
||||
|
||||
sort(field) {
|
||||
if (this.sortField === field) {
|
||||
this.sortDir = this.sortDir === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
this.sortField = field;
|
||||
this.sortDir = "desc";
|
||||
}
|
||||
this.reorder();
|
||||
},
|
||||
|
||||
reorder() {
|
||||
const tbody = this.$refs.tbody;
|
||||
if (!tbody) return;
|
||||
const rows = Array.from(tbody.querySelectorAll("tr"));
|
||||
const field = this.sortField;
|
||||
const dir = this.sortDir === "asc" ? 1 : -1;
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const aVal = parseFloat(a.dataset[field]) || 0;
|
||||
const bVal = parseFloat(b.dataset[field]) || 0;
|
||||
if (aVal !== bVal) return (aVal - bVal) * dir;
|
||||
// Tiebreak: alphabetical by player name
|
||||
const aName = (a.dataset.name || "").toLowerCase();
|
||||
const bName = (b.dataset.name || "").toLowerCase();
|
||||
return aName < bName ? -1 : aName > bName ? 1 : 0;
|
||||
});
|
||||
|
||||
rows.forEach((row) => tbody.appendChild(row));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
104
internal/handlers/player_link_slapid.go
Normal file
104
internal/handlers/player_link_slapid.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||
playersview "git.haelnorr.com/h/oslstats/internal/view/playersview"
|
||||
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// LinkPlayerSlapID handles the HTMX POST request to link a player's Slapshot ID
|
||||
// via their Discord Steam connection. Only the player's owner can trigger this.
|
||||
func LinkPlayerSlapID(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
slapAPI *slapshotapi.SlapAPI,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
playerIDStr := r.PathValue("player_id")
|
||||
|
||||
playerID, err := strconv.Atoi(playerIDStr)
|
||||
if err != nil {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
var player *db.Player
|
||||
|
||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
user := db.CurrentUser(ctx)
|
||||
if user == nil {
|
||||
throw.Unauthorized(s, w, r, "You must be logged in", errors.New("user not authenticated"))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var err error
|
||||
player, err = db.GetPlayer(ctx, tx, playerID)
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.GetPlayer")
|
||||
}
|
||||
|
||||
// Verify the current user owns this player
|
||||
if player.UserID == nil || *player.UserID != user.ID {
|
||||
throw.ForbiddenSecurity(s, w, r, "You can only link your own player", errors.New("user does not own player"))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Player already has a SlapID
|
||||
if player.SlapID != nil {
|
||||
notify.Info(s, w, r, "Already Linked", "Your Slapshot ID is already linked", nil)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Get the user's discord token to look up steam connection
|
||||
discordToken, err := user.GetDiscordToken(ctx, tx)
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
notify.Warn(s, w, r, "Link Failed", "Discord token not found. Please log out and log back in.", nil)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "user.GetDiscordToken")
|
||||
}
|
||||
|
||||
audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user)
|
||||
err = ConnectSlapID(ctx, tx, user, discordToken.Convert(), slapAPI, audit)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "ConnectSlapID")
|
||||
}
|
||||
|
||||
// Re-fetch the player to check if SlapID was set
|
||||
player, err = db.GetPlayer(ctx, tx, playerID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayer")
|
||||
}
|
||||
|
||||
if player.SlapID == nil {
|
||||
// ConnectSlapID returned nil (silent failure) - no steam or no slapID
|
||||
notify.Warn(s, w, r, "Link Failed",
|
||||
"Could not find your Slapshot ID. Make sure your Steam account is connected to Discord and you have played Slapshot: Rebound.",
|
||||
nil)
|
||||
} else {
|
||||
notify.Success(s, w, r, "Success", "Your Slapshot ID has been linked!", nil)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Re-render the slap ID section with updated state
|
||||
renderSafely(playersview.SlapIDSection(player, true), s, r, w)
|
||||
})
|
||||
}
|
||||
93
internal/handlers/player_stats_filter.go
Normal file
93
internal/handlers/player_stats_filter.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
playersview "git.haelnorr.com/h/oslstats/internal/view/playersview"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// PlayerStatsFilter handles HTMX POST requests to filter player stats
|
||||
// by season or team. Only one filter can be active at a time.
|
||||
// Query params: filter=season|team, filter_id=<id>
|
||||
func PlayerStatsFilter(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
player, _, ok := resolvePlayerAndOwner(s, conn, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
filterType := r.URL.Query().Get("filter")
|
||||
filterIDStr := r.URL.Query().Get("filter_id")
|
||||
|
||||
var stats *db.PlayerAllTimeStats
|
||||
var seasons []*db.Season
|
||||
var teams []*db.Team
|
||||
var activeFilter string
|
||||
var activeFilterID int
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
|
||||
// Load filter dropdown data
|
||||
seasons, err = db.GetPlayerSeasonsList(ctx, tx, player.ID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayerSeasonsList")
|
||||
}
|
||||
teams, err = db.GetPlayerTeamsList(ctx, tx, player.ID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayerTeamsList")
|
||||
}
|
||||
|
||||
// Apply filter
|
||||
filterID, _ := strconv.Atoi(filterIDStr)
|
||||
switch filterType {
|
||||
case "season":
|
||||
if filterID > 0 {
|
||||
stats, err = db.GetPlayerStatsBySeason(ctx, tx, player.ID, filterID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayerStatsBySeason")
|
||||
}
|
||||
activeFilter = "season"
|
||||
activeFilterID = filterID
|
||||
}
|
||||
case "team":
|
||||
if filterID > 0 {
|
||||
stats, err = db.GetPlayerStatsByTeam(ctx, tx, player.ID, filterID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayerStatsByTeam")
|
||||
}
|
||||
activeFilter = "team"
|
||||
activeFilterID = filterID
|
||||
}
|
||||
}
|
||||
|
||||
// Default to all-time stats if no valid filter
|
||||
if stats == nil {
|
||||
stats, err = db.GetPlayerAllTimeStats(ctx, tx, player.ID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayerAllTimeStats")
|
||||
}
|
||||
activeFilter = ""
|
||||
activeFilterID = 0
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
renderSafely(playersview.PlayerStatsTab(
|
||||
player, stats, seasons, teams,
|
||||
activeFilter, activeFilterID,
|
||||
), s, r, w)
|
||||
})
|
||||
}
|
||||
186
internal/handlers/player_view.go
Normal file
186
internal/handlers/player_view.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||
playersview "git.haelnorr.com/h/oslstats/internal/view/playersview"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// ProfileRedirect redirects the authenticated user to their own player page.
|
||||
func ProfileRedirect(
|
||||
s *hws.Server,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := db.CurrentUser(r.Context())
|
||||
if user == nil {
|
||||
throw.Unauthorized(s, w, r, "You must be logged in to view your profile", errors.New("user not authenticated"))
|
||||
return
|
||||
}
|
||||
if user.Player == nil {
|
||||
throw.InternalServiceError(s, w, r, "Player profile not found", errors.New("user has no linked player"))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/players/%d", user.Player.ID), http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
|
||||
// resolvePlayerAndOwner is a helper that resolves the player from the URL path
|
||||
// and determines if the current user is the owner of the player.
|
||||
// Returns false from the outer handler if resolution failed (404 already thrown).
|
||||
func resolvePlayerAndOwner(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) (player *db.Player, isOwner bool, ok bool) {
|
||||
playerIDStr := r.PathValue("player_id")
|
||||
playerID, err := strconv.Atoi(playerIDStr)
|
||||
if err != nil {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return nil, false, false
|
||||
}
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
player, err = db.GetPlayer(ctx, tx, playerID)
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.GetPlayer")
|
||||
}
|
||||
|
||||
user := db.CurrentUser(ctx)
|
||||
if user != nil && player.UserID != nil && *player.UserID == user.ID {
|
||||
isOwner = true
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return nil, false, false
|
||||
}
|
||||
|
||||
// If player has no SlapID and viewer is not the owner, show 404
|
||||
if player.SlapID == nil && !isOwner {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return nil, false, false
|
||||
}
|
||||
|
||||
return player, isOwner, true
|
||||
}
|
||||
|
||||
// PlayerViewStats renders the player profile page with the stats tab active.
|
||||
// GET renders the full page layout. POST renders just the tab content.
|
||||
func PlayerViewStats(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var stats *db.PlayerAllTimeStats
|
||||
var seasons []*db.Season
|
||||
var teams []*db.Team
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
stats, err = db.GetPlayerAllTimeStats(ctx, tx, player.ID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayerAllTimeStats")
|
||||
}
|
||||
seasons, err = db.GetPlayerSeasonsList(ctx, tx, player.ID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayerSeasonsList")
|
||||
}
|
||||
teams, err = db.GetPlayerTeamsList(ctx, tx, player.ID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayerTeamsList")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "GET" {
|
||||
renderSafely(playersview.PlayerStatsPage(player, isOwner, stats, seasons, teams), s, r, w)
|
||||
} else {
|
||||
renderSafely(playersview.PlayerStatsTab(player, stats, seasons, teams, "", 0), s, r, w)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// PlayerViewTeams renders the teams tab of the player profile page.
|
||||
func PlayerViewTeams(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var teamInfos []*db.PlayerTeamInfo
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
teamInfos, err = db.GetPlayerTeams(ctx, tx, player.ID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayerTeams")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "GET" {
|
||||
renderSafely(playersview.PlayerTeamsPage(player, isOwner, teamInfos), s, r, w)
|
||||
} else {
|
||||
renderSafely(playersview.PlayerTeamsTab(teamInfos), s, r, w)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// PlayerViewSeasons renders the seasons tab of the player profile page.
|
||||
func PlayerViewSeasons(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var seasonInfos []*db.PlayerSeasonInfo
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
seasonInfos, err = db.GetPlayerSeasons(ctx, tx, player.ID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayerSeasons")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "GET" {
|
||||
renderSafely(playersview.PlayerSeasonsPage(player, isOwner, seasonInfos), s, r, w)
|
||||
} else {
|
||||
renderSafely(playersview.PlayerSeasonsTab(seasonInfos), s, r, w)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
67
internal/handlers/team_detail.go
Normal file
67
internal/handlers/team_detail.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||
teamsview "git.haelnorr.com/h/oslstats/internal/view/teamsview"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// TeamDetailPage renders the global team detail page showing cross-season stats
|
||||
func TeamDetailPage(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
teamIDStr := r.PathValue("team_id")
|
||||
|
||||
teamID, err := strconv.Atoi(teamIDStr)
|
||||
if err != nil {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
var team *db.Team
|
||||
var seasonInfos []*db.TeamSeasonInfo
|
||||
var playerStats []*db.TeamAllTimePlayerStats
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
team, err = db.GetTeam(ctx, tx, teamID)
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.GetTeam")
|
||||
}
|
||||
|
||||
seasonInfos, err = db.GetTeamSeasonParticipation(ctx, tx, teamID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetTeamSeasonParticipation")
|
||||
}
|
||||
|
||||
playerStats, err = db.GetTeamAllTimePlayerStats(ctx, tx, teamID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetTeamAllTimePlayerStats")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
activeTab := r.URL.Query().Get("tab")
|
||||
if activeTab != "seasons" && activeTab != "stats" {
|
||||
activeTab = "seasons"
|
||||
}
|
||||
|
||||
renderSafely(teamsview.DetailPage(team, seasonInfos, playerStats, activeTab), s, r, w)
|
||||
})
|
||||
}
|
||||
@@ -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...)
|
||||
|
||||
54
internal/view/component/links/links.templ
Normal file
54
internal/view/component/links/links.templ
Normal file
@@ -0,0 +1,54 @@
|
||||
package links
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "fmt"
|
||||
|
||||
// PlayerLink renders a player name as a clickable link to their profile page.
|
||||
// The player's DisplayName() is used as the link text.
|
||||
templ PlayerLink(player *db.Player) {
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/players/%d", player.ID)) }
|
||||
class="text-text hover:text-blue transition"
|
||||
>
|
||||
{ player.DisplayName() }
|
||||
</a>
|
||||
}
|
||||
|
||||
// PlayerLinkFromStats renders a player name link using a player ID and name string.
|
||||
// This is useful when only aggregated stats are available (no full Player object).
|
||||
templ PlayerLinkFromStats(playerID int, playerName string) {
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/players/%d", playerID)) }
|
||||
class="text-text hover:text-blue transition"
|
||||
>
|
||||
{ playerName }
|
||||
</a>
|
||||
}
|
||||
|
||||
// TeamLinkInSeason renders a team name as a clickable link to the team's
|
||||
// season-specific detail page, with an optional color dot prefix.
|
||||
templ TeamLinkInSeason(team *db.Team, season *db.Season, league *db.League) {
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams/%d", season.ShortName, league.ShortName, team.ID)) }
|
||||
class="flex items-center gap-2 hover:text-blue transition"
|
||||
>
|
||||
if team.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
||||
></span>
|
||||
}
|
||||
<span class="text-sm font-medium">{ team.Name }</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
// TeamNameLinkInSeason renders just the team name as a clickable link (no color dot).
|
||||
// Useful where the color dot is already rendered separately or in inline contexts.
|
||||
templ TeamNameLinkInSeason(team *db.Team, season *db.Season, league *db.League) {
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams/%d", season.ShortName, league.ShortName, team.ID)) }
|
||||
class="hover:text-blue transition"
|
||||
>
|
||||
{ team.Name }
|
||||
</a>
|
||||
}
|
||||
97
internal/view/playersview/player_page.templ
Normal file
97
internal/view/playersview/player_page.templ
Normal file
@@ -0,0 +1,97 @@
|
||||
package playersview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||
import "fmt"
|
||||
|
||||
templ PlayerLayout(activeSection string, player *db.Player, isOwner bool) {
|
||||
@baseview.Layout(player.DisplayName() + " - Player Profile") {
|
||||
<div class="max-w-screen-2xl mx-auto px-4 py-8">
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h1 class="text-4xl font-bold text-text">{ player.DisplayName() }</h1>
|
||||
if isOwner {
|
||||
<span class="px-2 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium">
|
||||
Your Profile
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
if player.SlapID != nil {
|
||||
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||||
Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SlapID Link Prompt (if needed) -->
|
||||
if player.SlapID == nil && isOwner {
|
||||
<div class="px-6 pt-6">
|
||||
@SlapIDSection(player, isOwner)
|
||||
</div>
|
||||
}
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="bg-surface0 border-b border-surface1" data-tab-nav="player-content">
|
||||
<ul class="flex flex-wrap">
|
||||
@playerNavItem("stats", "Stats", activeSection, player)
|
||||
@playerNavItem("teams", "Teams", activeSection, player)
|
||||
@playerNavItem("seasons", "Seasons", activeSection, player)
|
||||
</ul>
|
||||
</nav>
|
||||
<!-- Content Area -->
|
||||
<main class="bg-crust p-6" id="player-content">
|
||||
{ children... }
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/tabs.js" defer></script>
|
||||
}
|
||||
}
|
||||
|
||||
templ playerNavItem(section string, label string, activeSection string, player *db.Player) {
|
||||
{{
|
||||
isActive := section == activeSection
|
||||
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
|
||||
activeClasses := "border-blue text-blue font-semibold"
|
||||
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
||||
url := fmt.Sprintf("/players/%d/%s", player.ID, section)
|
||||
}}
|
||||
<li class="inline-block">
|
||||
<a
|
||||
href={ templ.SafeURL(url) }
|
||||
hx-post={ url }
|
||||
hx-target="#player-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url={ url }
|
||||
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||||
>
|
||||
{ label }
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
|
||||
// Full page wrappers (for GET requests / direct navigation)
|
||||
|
||||
templ PlayerStatsPage(player *db.Player, isOwner bool, stats *db.PlayerAllTimeStats, seasons []*db.Season, teams []*db.Team) {
|
||||
@PlayerLayout("stats", player, isOwner) {
|
||||
@PlayerStatsTab(player, stats, seasons, teams, "", 0)
|
||||
}
|
||||
}
|
||||
|
||||
templ PlayerTeamsPage(player *db.Player, isOwner bool, teamInfos []*db.PlayerTeamInfo) {
|
||||
@PlayerLayout("teams", player, isOwner) {
|
||||
@PlayerTeamsTab(teamInfos)
|
||||
}
|
||||
}
|
||||
|
||||
templ PlayerSeasonsPage(player *db.Player, isOwner bool, seasonInfos []*db.PlayerSeasonInfo) {
|
||||
@PlayerLayout("seasons", player, isOwner) {
|
||||
@PlayerSeasonsTab(seasonInfos)
|
||||
}
|
||||
}
|
||||
73
internal/view/playersview/player_seasons_tab.templ
Normal file
73
internal/view/playersview/player_seasons_tab.templ
Normal file
@@ -0,0 +1,73 @@
|
||||
package playersview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "fmt"
|
||||
|
||||
templ PlayerSeasonsTab(seasonInfos []*db.PlayerSeasonInfo) {
|
||||
if len(seasonInfos) == 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No season history yet.</p>
|
||||
<p class="text-subtext1 text-sm mt-2">This player has not participated in any seasons.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Season</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-text">League</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Team</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-text">Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for _, info := range seasonInfos {
|
||||
<tr class="hover:bg-surface1 transition-colors">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s", info.Season.ShortName)) }
|
||||
class="text-blue hover:text-blue/80 transition"
|
||||
>
|
||||
{ info.Season.Name }
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-subtext0">
|
||||
{ info.League.Name }
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf(
|
||||
"/seasons/%s/leagues/%s/teams/%d",
|
||||
info.Season.ShortName, info.League.ShortName, info.Team.ID,
|
||||
)) }
|
||||
class="text-blue hover:text-blue/80 transition"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
if info.Team.Color != "" {
|
||||
<div
|
||||
class="w-3 h-3 rounded-full border border-surface1 shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(info.Team.Color) }
|
||||
></div>
|
||||
}
|
||||
<span>{ info.Team.Name }</span>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
if info.IsManager {
|
||||
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
Manager
|
||||
</span>
|
||||
} else {
|
||||
<span class="text-subtext1 text-xs">Player</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
130
internal/view/playersview/player_stats_tab.templ
Normal file
130
internal/view/playersview/player_stats_tab.templ
Normal file
@@ -0,0 +1,130 @@
|
||||
package playersview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "fmt"
|
||||
|
||||
templ PlayerStatsTab(player *db.Player, stats *db.PlayerAllTimeStats, seasons []*db.Season, teams []*db.Team, activeFilter string, activeFilterID int) {
|
||||
<div class="space-y-6" data-filter-url={ fmt.Sprintf("/players/%d/stats/filter", player.ID) }>
|
||||
<!-- Filter Controls -->
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<!-- Season Filter -->
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-subtext0 uppercase font-medium mb-1">Filter by Season</label>
|
||||
<select
|
||||
name="season_id"
|
||||
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||
onchange={ handleFilterChange("season") }
|
||||
>
|
||||
<option value="">All Seasons</option>
|
||||
for _, s := range seasons {
|
||||
<option
|
||||
value={ fmt.Sprint(s.ID) }
|
||||
selected?={ activeFilter == "season" && activeFilterID == s.ID }
|
||||
>
|
||||
{ s.Name }
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Team Filter -->
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-subtext0 uppercase font-medium mb-1">Filter by Team</label>
|
||||
<select
|
||||
name="team_id"
|
||||
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||
onchange={ handleFilterChange("team") }
|
||||
>
|
||||
<option value="">All Teams</option>
|
||||
for _, t := range teams {
|
||||
<option
|
||||
value={ fmt.Sprint(t.ID) }
|
||||
selected?={ activeFilter == "team" && activeFilterID == t.ID }
|
||||
>
|
||||
{ t.Name }
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filter Label -->
|
||||
<div class="text-sm text-subtext0">
|
||||
if activeFilter == "" {
|
||||
Showing <span class="text-text font-medium">All-Time</span> stats
|
||||
} else if activeFilter == "season" {
|
||||
Showing stats for season:
|
||||
<span class="text-text font-medium">
|
||||
{ getSeasonName(seasons, activeFilterID) }
|
||||
</span>
|
||||
} else if activeFilter == "team" {
|
||||
Showing stats for team:
|
||||
<span class="text-text font-medium">
|
||||
{ getTeamName(teams, activeFilterID) }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Stats Grid -->
|
||||
@playerStatsGrid(stats)
|
||||
</div>
|
||||
}
|
||||
|
||||
templ playerStatsGrid(stats *db.PlayerAllTimeStats) {
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
@statCard("Games Played", fmt.Sprint(stats.GamesPlayed), "text-blue")
|
||||
@statCard("Goals", fmt.Sprint(stats.Goals), "text-green")
|
||||
@statCard("Assists", fmt.Sprint(stats.Assists), "text-teal")
|
||||
@statCard("Saves", fmt.Sprint(stats.Saves), "text-yellow")
|
||||
@statCard("Shots", fmt.Sprint(stats.Shots), "text-peach")
|
||||
@statCard("Blocks", fmt.Sprint(stats.Blocks), "text-mauve")
|
||||
@statCard("Passes", fmt.Sprint(stats.Passes), "text-sky")
|
||||
@statCard("Periods Played", fmt.Sprint(stats.PeriodsPlayed), "text-subtext0")
|
||||
</div>
|
||||
}
|
||||
|
||||
templ statCard(label string, value string, colorClass string) {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-4 text-center">
|
||||
<p class="text-xs text-subtext0 uppercase font-medium mb-1">{ label }</p>
|
||||
<p class={ "text-2xl font-bold", colorClass }>{ value }</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
script handleFilterChange(filterType string) {
|
||||
var container = event.target.closest("[data-filter-url]")
|
||||
if (!container) return
|
||||
|
||||
var baseUrl = container.getAttribute("data-filter-url")
|
||||
var seasonSelect = container.querySelector("select[name='season_id']")
|
||||
var teamSelect = container.querySelector("select[name='team_id']")
|
||||
|
||||
// Reset the other filter when one is selected
|
||||
if (filterType === "season" && teamSelect) {
|
||||
teamSelect.value = ""
|
||||
} else if (filterType === "team" && seasonSelect) {
|
||||
seasonSelect.value = ""
|
||||
}
|
||||
|
||||
var value = event.target.value
|
||||
var url = baseUrl
|
||||
if (value) {
|
||||
url += "?filter=" + filterType + "&filter_id=" + value
|
||||
}
|
||||
|
||||
htmx.ajax("POST", url, {target: "#player-content", swap: "innerHTML"})
|
||||
}
|
||||
|
||||
func getSeasonName(seasons []*db.Season, id int) string {
|
||||
for _, s := range seasons {
|
||||
if s.ID == id {
|
||||
return s.Name
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
func getTeamName(teams []*db.Team, id int) string {
|
||||
for _, t := range teams {
|
||||
if t.ID == id {
|
||||
return t.Name
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
51
internal/view/playersview/player_teams_tab.templ
Normal file
51
internal/view/playersview/player_teams_tab.templ
Normal file
@@ -0,0 +1,51 @@
|
||||
package playersview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "fmt"
|
||||
|
||||
templ PlayerTeamsTab(teamInfos []*db.PlayerTeamInfo) {
|
||||
if len(teamInfos) == 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No team history yet.</p>
|
||||
<p class="text-subtext1 text-sm mt-2">This player has not been on any teams.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Team</th>
|
||||
<th class="px-4 py-3 text-right text-sm font-semibold text-text">Seasons Played</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for _, info := range teamInfos {
|
||||
<tr class="hover:bg-surface1 transition-colors">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/teams/%d", info.Team.ID)) }
|
||||
class="text-blue hover:text-blue/80 transition"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
if info.Team.Color != "" {
|
||||
<div
|
||||
class="w-4 h-4 rounded-full border border-surface1 shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(info.Team.Color) }
|
||||
></div>
|
||||
}
|
||||
<span>{ info.Team.Name }</span>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-subtext0 text-right">
|
||||
{ fmt.Sprint(info.SeasonsCount) }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
52
internal/view/playersview/slap_id_section.templ
Normal file
52
internal/view/playersview/slap_id_section.templ
Normal file
@@ -0,0 +1,52 @@
|
||||
package playersview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "fmt"
|
||||
|
||||
templ SlapIDSection(player *db.Player, isOwner bool) {
|
||||
<div id="slap-id-section">
|
||||
if player.SlapID == nil && isOwner {
|
||||
@slapIDLinkPrompt(player)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ slapIDLinkPrompt(player *db.Player) {
|
||||
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<svg class="w-6 h-6 text-yellow shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
></path>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-yellow mb-2">Slapshot ID Not Linked</h3>
|
||||
<p class="text-subtext0 mb-4">
|
||||
Your Slapshot ID is not linked. Please link your Steam account to your Discord account, then click the button below to connect your Slapshot ID.
|
||||
</p>
|
||||
<p class="text-subtext1 text-sm mb-4">
|
||||
Need help linking Steam to Discord?
|
||||
<a
|
||||
href="https://support.discord.com/hc/en-us/articles/32330173689623-Account-Connections-on-Discord-FAQ#h_01JVZBVNC1HYWX4BTPFN9B4B1V"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue hover:text-blue/80 underline transition"
|
||||
>
|
||||
Follow this guide
|
||||
</a>
|
||||
</p>
|
||||
<button
|
||||
hx-post={ fmt.Sprintf("/players/%d/link-slapid", player.ID) }
|
||||
hx-target="#slap-id-section"
|
||||
hx-swap="outerHTML"
|
||||
class="px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Link Slapshot ID
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -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)) {
|
||||
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
||||
@@ -70,19 +80,26 @@ templ FixtureDetailPage(
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab Navigation (hidden when only one tab) -->
|
||||
if !isFinalized {
|
||||
<nav class="bg-surface0 border-b border-surface1">
|
||||
<ul class="flex flex-wrap">
|
||||
@fixtureTabItem("overview", "Overview", activeTab, fixture)
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="bg-surface0 border-b border-surface1">
|
||||
<ul class="flex flex-wrap">
|
||||
@fixtureTabItem("overview", "Overview", activeTab, fixture)
|
||||
if isFinalized {
|
||||
@fixtureTabItem("analysis", "Match Analysis", activeTab, fixture)
|
||||
} else {
|
||||
@fixtureTabItem("preview", "Match Preview", activeTab, fixture)
|
||||
@fixtureTabItem("schedule", "Schedule", activeTab, fixture)
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<!-- Tab Content -->
|
||||
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(
|
||||
}
|
||||
<!-- Team Rosters -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@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)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -603,7 +620,7 @@ templ forfeitModal(fixture *db.Fixture) {
|
||||
</div>
|
||||
}
|
||||
|
||||
templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side string, result *db.FixtureResult) {
|
||||
templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side string, result *db.FixtureResult, season *db.Season, league *db.League) {
|
||||
{{
|
||||
// Separate playing and bench players
|
||||
var playing []*db.PlayerWithPlayStatus
|
||||
@@ -640,8 +657,8 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
||||
}}
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||
<h3 class="text-md font-bold text-text">
|
||||
{ team.Name }
|
||||
<h3 class="text-md font-bold">
|
||||
@links.TeamNameLinkInSeason(team, season, league)
|
||||
</h3>
|
||||
if team.Color != "" {
|
||||
<span
|
||||
@@ -662,6 +679,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
||||
<thead class="bg-surface0 border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
||||
@@ -674,9 +692,9 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for _, p := range playing {
|
||||
<tr class="hover:bg-surface0 transition-colors">
|
||||
<td class="px-3 py-2 text-sm text-text">
|
||||
<td class="px-3 py-2 text-sm">
|
||||
<span class="flex items-center gap-1.5">
|
||||
{ p.Player.DisplayName() }
|
||||
@links.PlayerLink(p.Player)
|
||||
if p.IsManager {
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
★
|
||||
@@ -690,6 +708,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
||||
</span>
|
||||
</td>
|
||||
if p.Stats != nil {
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ intPtrStr(p.Stats.PeriodsPlayed) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ intPtrStr(p.Stats.Score) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Goals) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Assists) }</td>
|
||||
@@ -698,7 +717,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Blocks) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Passes) }</td>
|
||||
} else {
|
||||
<td colspan="7" class="px-2 py-2 text-center text-xs text-subtext1">—</td>
|
||||
<td colspan="8" class="px-2 py-2 text-center text-xs text-subtext1">—</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
@@ -713,7 +732,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
||||
for _, p := range bench {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded">
|
||||
<span class="text-sm text-subtext1">
|
||||
{ p.Player.DisplayName() }
|
||||
@links.PlayerLink(p.Player)
|
||||
</span>
|
||||
if p.IsManager {
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
@@ -735,8 +754,8 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
||||
<div class="space-y-1">
|
||||
for _, p := range playing {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface0 transition">
|
||||
<span class="text-sm text-text">
|
||||
{ p.Player.DisplayName() }
|
||||
<span class="text-sm">
|
||||
@links.PlayerLink(p.Player)
|
||||
</span>
|
||||
if p.IsManager {
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
@@ -758,7 +777,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
||||
for _, p := range bench {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded">
|
||||
<span class="text-sm text-subtext1">
|
||||
{ p.Player.DisplayName() }
|
||||
@links.PlayerLink(p.Player)
|
||||
</span>
|
||||
if p.IsManager {
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
@@ -838,7 +857,9 @@ templ fixtureFreeAgentSection(
|
||||
for _, n := range homeNominated {
|
||||
<div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="text-sm text-text">{ n.Player.DisplayName() }</span>
|
||||
<span class="text-sm">
|
||||
@links.PlayerLink(n.Player)
|
||||
</span>
|
||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||
FA
|
||||
</span>
|
||||
@@ -873,7 +894,9 @@ templ fixtureFreeAgentSection(
|
||||
for _, n := range awayNominated {
|
||||
<div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="text-sm text-text">{ n.Player.DisplayName() }</span>
|
||||
<span class="text-sm">
|
||||
@links.PlayerLink(n.Player)
|
||||
</span>
|
||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||
FA
|
||||
</span>
|
||||
|
||||
611
internal/view/seasonsview/fixture_match_analysis.templ
Normal file
611
internal/view/seasonsview/fixture_match_analysis.templ
Normal file
@@ -0,0 +1,611 @@
|
||||
package seasonsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||
import "fmt"
|
||||
import "sort"
|
||||
import "strings"
|
||||
|
||||
// teamAggStats holds aggregated stats for a single team in a fixture.
|
||||
type teamAggStats struct {
|
||||
Goals int
|
||||
Assists int
|
||||
PrimaryAssists int
|
||||
SecondaryAssists int
|
||||
Saves int
|
||||
Shots int
|
||||
Blocks int
|
||||
Passes int
|
||||
Turnovers int
|
||||
Takeaways int
|
||||
FaceoffsWon int
|
||||
FaceoffsLost int
|
||||
PostHits int
|
||||
PossessionSec int
|
||||
PlayersUsed int
|
||||
}
|
||||
|
||||
func aggregateTeamStats(players []*db.PlayerWithPlayStatus) *teamAggStats {
|
||||
agg := &teamAggStats{}
|
||||
for _, p := range players {
|
||||
if !p.Played || p.Stats == nil {
|
||||
continue
|
||||
}
|
||||
agg.PlayersUsed++
|
||||
if p.Stats.Goals != nil {
|
||||
agg.Goals += *p.Stats.Goals
|
||||
}
|
||||
if p.Stats.Assists != nil {
|
||||
agg.Assists += *p.Stats.Assists
|
||||
}
|
||||
if p.Stats.PrimaryAssists != nil {
|
||||
agg.PrimaryAssists += *p.Stats.PrimaryAssists
|
||||
}
|
||||
if p.Stats.SecondaryAssists != nil {
|
||||
agg.SecondaryAssists += *p.Stats.SecondaryAssists
|
||||
}
|
||||
if p.Stats.Saves != nil {
|
||||
agg.Saves += *p.Stats.Saves
|
||||
}
|
||||
if p.Stats.Shots != nil {
|
||||
agg.Shots += *p.Stats.Shots
|
||||
}
|
||||
if p.Stats.Blocks != nil {
|
||||
agg.Blocks += *p.Stats.Blocks
|
||||
}
|
||||
if p.Stats.Passes != nil {
|
||||
agg.Passes += *p.Stats.Passes
|
||||
}
|
||||
if p.Stats.Turnovers != nil {
|
||||
agg.Turnovers += *p.Stats.Turnovers
|
||||
}
|
||||
if p.Stats.Takeaways != nil {
|
||||
agg.Takeaways += *p.Stats.Takeaways
|
||||
}
|
||||
if p.Stats.FaceoffsWon != nil {
|
||||
agg.FaceoffsWon += *p.Stats.FaceoffsWon
|
||||
}
|
||||
if p.Stats.FaceoffsLost != nil {
|
||||
agg.FaceoffsLost += *p.Stats.FaceoffsLost
|
||||
}
|
||||
if p.Stats.PostHits != nil {
|
||||
agg.PostHits += *p.Stats.PostHits
|
||||
}
|
||||
if p.Stats.PossessionTimeSec != nil {
|
||||
agg.PossessionSec += *p.Stats.PossessionTimeSec
|
||||
}
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
func formatPossession(seconds int) string {
|
||||
m := seconds / 60
|
||||
s := seconds % 60
|
||||
return fmt.Sprintf("%d:%02d", m, s)
|
||||
}
|
||||
|
||||
func faceoffPct(won, lost int) string {
|
||||
total := won + lost
|
||||
if total == 0 {
|
||||
return "0%"
|
||||
}
|
||||
pct := float64(won) / float64(total) * 100
|
||||
return fmt.Sprintf("%.0f%%", pct)
|
||||
}
|
||||
|
||||
// fixtureMatchAnalysisTab renders the full Match Analysis tab for completed fixtures.
|
||||
// Shows score, team stats comparison, match details, and top performers.
|
||||
templ fixtureMatchAnalysisTab(
|
||||
fixture *db.Fixture,
|
||||
result *db.FixtureResult,
|
||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||
preview *db.MatchPreviewData,
|
||||
) {
|
||||
<div class="space-y-6">
|
||||
<!-- Score Display -->
|
||||
@analysisScoreHeader(fixture, result)
|
||||
|
||||
<!-- Team Stats Comparison -->
|
||||
@analysisTeamStatsComparison(fixture, rosters)
|
||||
|
||||
<!-- Top Performers -->
|
||||
@analysisTopPerformers(fixture, rosters)
|
||||
|
||||
<!-- Standings Context (from preview data) -->
|
||||
if preview != nil {
|
||||
@analysisStandingsContext(fixture, preview)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// analysisScoreHeader renders the final score in a prominent broadcast-style display.
|
||||
templ analysisScoreHeader(fixture *db.Fixture, result *db.FixtureResult) {
|
||||
{{
|
||||
isOT := strings.EqualFold(result.EndReason, "Overtime")
|
||||
homeWon := result.Winner == "home"
|
||||
awayWon := result.Winner == "away"
|
||||
isForfeit := result.IsForfeit
|
||||
}}
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Final Score</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
if isForfeit {
|
||||
@analysisForfeitDisplay(fixture, result)
|
||||
} else {
|
||||
<div class="flex items-center justify-center gap-6 sm:gap-10">
|
||||
<!-- Home Team -->
|
||||
<div class="flex flex-col items-center text-center flex-1">
|
||||
if fixture.HomeTeam.Color != "" {
|
||||
<div
|
||||
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||
></div>
|
||||
}
|
||||
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
|
||||
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
|
||||
</h3>
|
||||
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", homeWon), templ.KV("text-text", !homeWon) }>
|
||||
{ fmt.Sprint(result.HomeScore) }
|
||||
</span>
|
||||
if homeWon {
|
||||
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Divider -->
|
||||
<div class="flex flex-col items-center shrink-0">
|
||||
<span class="text-4xl text-subtext0 font-light">–</span>
|
||||
if isOT {
|
||||
<span class="mt-1 px-2 py-0.5 bg-peach/20 text-peach rounded text-xs font-bold">OT</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Away Team -->
|
||||
<div class="flex flex-col items-center text-center flex-1">
|
||||
if fixture.AwayTeam.Color != "" {
|
||||
<div
|
||||
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||
></div>
|
||||
}
|
||||
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
|
||||
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
|
||||
</h3>
|
||||
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", awayWon), templ.KV("text-text", !awayWon) }>
|
||||
{ fmt.Sprint(result.AwayScore) }
|
||||
</span>
|
||||
if awayWon {
|
||||
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// analysisForfeitDisplay renders a forfeit result in the analysis header.
|
||||
templ analysisForfeitDisplay(fixture *db.Fixture, result *db.FixtureResult) {
|
||||
{{
|
||||
isMutualForfeit := result.ForfeitType != nil && *result.ForfeitType == "mutual"
|
||||
isOutrightForfeit := result.ForfeitType != nil && *result.ForfeitType == "outright"
|
||||
forfeitTeamName := ""
|
||||
winnerTeamName := ""
|
||||
if isOutrightForfeit && result.ForfeitTeam != nil {
|
||||
if *result.ForfeitTeam == "home" {
|
||||
forfeitTeamName = fixture.HomeTeam.Name
|
||||
winnerTeamName = fixture.AwayTeam.Name
|
||||
} else {
|
||||
forfeitTeamName = fixture.AwayTeam.Name
|
||||
winnerTeamName = fixture.HomeTeam.Name
|
||||
}
|
||||
}
|
||||
}}
|
||||
<div class="flex flex-col items-center py-4 space-y-4">
|
||||
if isMutualForfeit {
|
||||
<span class="px-4 py-2 bg-peach/20 text-peach rounded-lg text-lg font-bold">MUTUAL FORFEIT</span>
|
||||
<p class="text-sm text-subtext0">Both teams receive an overtime loss</p>
|
||||
} else if isOutrightForfeit {
|
||||
<span class="px-4 py-2 bg-red/20 text-red rounded-lg text-lg font-bold">FORFEIT</span>
|
||||
<p class="text-sm text-subtext0">
|
||||
{ forfeitTeamName } forfeited — { winnerTeamName } wins
|
||||
</p>
|
||||
}
|
||||
if result.ForfeitReason != nil && *result.ForfeitReason != "" {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-3 max-w-md w-full text-center">
|
||||
<p class="text-xs text-subtext1 font-medium mb-1">Reason</p>
|
||||
<p class="text-sm text-subtext0">{ *result.ForfeitReason }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// analysisTeamStatsComparison renders aggregated team stats in the broadcast comparison layout.
|
||||
templ analysisTeamStatsComparison(fixture *db.Fixture, rosters map[string][]*db.PlayerWithPlayStatus) {
|
||||
{{
|
||||
homeAgg := aggregateTeamStats(rosters["home"])
|
||||
awayAgg := aggregateTeamStats(rosters["away"])
|
||||
}}
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Team Statistics</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- Team Name Headers -->
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-1 text-right pr-4">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
if fixture.HomeTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
<span class="text-sm font-bold text-text">{ fixture.HomeTeam.ShortName }</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-28 sm:w-36 text-center shrink-0"></div>
|
||||
<div class="flex-1 text-left pl-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-text">{ fixture.AwayTeam.ShortName }</span>
|
||||
if fixture.AwayTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stats Rows -->
|
||||
<div class="space-y-0">
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Goals),
|
||||
"Goals",
|
||||
fmt.Sprint(awayAgg.Goals),
|
||||
homeAgg.Goals > awayAgg.Goals,
|
||||
awayAgg.Goals > homeAgg.Goals,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Assists),
|
||||
"Assists",
|
||||
fmt.Sprint(awayAgg.Assists),
|
||||
homeAgg.Assists > awayAgg.Assists,
|
||||
awayAgg.Assists > homeAgg.Assists,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Shots),
|
||||
"Shots",
|
||||
fmt.Sprint(awayAgg.Shots),
|
||||
homeAgg.Shots > awayAgg.Shots,
|
||||
awayAgg.Shots > homeAgg.Shots,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Saves),
|
||||
"Saves",
|
||||
fmt.Sprint(awayAgg.Saves),
|
||||
homeAgg.Saves > awayAgg.Saves,
|
||||
awayAgg.Saves > homeAgg.Saves,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Blocks),
|
||||
"Blocks",
|
||||
fmt.Sprint(awayAgg.Blocks),
|
||||
homeAgg.Blocks > awayAgg.Blocks,
|
||||
awayAgg.Blocks > homeAgg.Blocks,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Passes),
|
||||
"Passes",
|
||||
fmt.Sprint(awayAgg.Passes),
|
||||
homeAgg.Passes > awayAgg.Passes,
|
||||
awayAgg.Passes > homeAgg.Passes,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Takeaways),
|
||||
"Takeaways",
|
||||
fmt.Sprint(awayAgg.Takeaways),
|
||||
homeAgg.Takeaways > awayAgg.Takeaways,
|
||||
awayAgg.Takeaways > homeAgg.Takeaways,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Turnovers),
|
||||
"Turnovers",
|
||||
fmt.Sprint(awayAgg.Turnovers),
|
||||
homeAgg.Turnovers < awayAgg.Turnovers,
|
||||
awayAgg.Turnovers < homeAgg.Turnovers,
|
||||
)
|
||||
<!-- Faceoffs -->
|
||||
{{
|
||||
homeFO := homeAgg.FaceoffsWon + homeAgg.FaceoffsLost
|
||||
awayFO := awayAgg.FaceoffsWon + awayAgg.FaceoffsLost
|
||||
homeFOStr := fmt.Sprintf("%d/%d", homeAgg.FaceoffsWon, homeFO)
|
||||
awayFOStr := fmt.Sprintf("%d/%d", awayAgg.FaceoffsWon, awayFO)
|
||||
}}
|
||||
@previewStatRow(
|
||||
homeFOStr,
|
||||
"Faceoffs Won",
|
||||
awayFOStr,
|
||||
homeAgg.FaceoffsWon > awayAgg.FaceoffsWon,
|
||||
awayAgg.FaceoffsWon > homeAgg.FaceoffsWon,
|
||||
)
|
||||
@previewStatRow(
|
||||
faceoffPct(homeAgg.FaceoffsWon, homeAgg.FaceoffsLost),
|
||||
"Faceoff %",
|
||||
faceoffPct(awayAgg.FaceoffsWon, awayAgg.FaceoffsLost),
|
||||
homeAgg.FaceoffsWon * (awayAgg.FaceoffsWon + awayAgg.FaceoffsLost) > awayAgg.FaceoffsWon * (homeAgg.FaceoffsWon + homeAgg.FaceoffsLost),
|
||||
awayAgg.FaceoffsWon * (homeAgg.FaceoffsWon + homeAgg.FaceoffsLost) > homeAgg.FaceoffsWon * (awayAgg.FaceoffsWon + awayAgg.FaceoffsLost),
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.PostHits),
|
||||
"Post Hits",
|
||||
fmt.Sprint(awayAgg.PostHits),
|
||||
homeAgg.PostHits > awayAgg.PostHits,
|
||||
awayAgg.PostHits > homeAgg.PostHits,
|
||||
)
|
||||
@previewStatRow(
|
||||
formatPossession(homeAgg.PossessionSec),
|
||||
"Possession",
|
||||
formatPossession(awayAgg.PossessionSec),
|
||||
homeAgg.PossessionSec > awayAgg.PossessionSec,
|
||||
awayAgg.PossessionSec > homeAgg.PossessionSec,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.PlayersUsed),
|
||||
"Players Used",
|
||||
fmt.Sprint(awayAgg.PlayersUsed),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// analysisTopPerformers shows the top players from each team based on score.
|
||||
templ analysisTopPerformers(fixture *db.Fixture, rosters map[string][]*db.PlayerWithPlayStatus) {
|
||||
{{
|
||||
// Collect players who played and have stats, sorted by score descending
|
||||
type scoredPlayer struct {
|
||||
Player *db.Player
|
||||
Stats *db.FixtureResultPlayerStats
|
||||
IsManager bool
|
||||
IsFreeAgent bool
|
||||
}
|
||||
|
||||
collectTop := func(players []*db.PlayerWithPlayStatus, limit int) []*scoredPlayer {
|
||||
var scored []*scoredPlayer
|
||||
for _, p := range players {
|
||||
if !p.Played || p.Stats == nil || p.Player == nil {
|
||||
continue
|
||||
}
|
||||
scored = append(scored, &scoredPlayer{
|
||||
Player: p.Player,
|
||||
Stats: p.Stats,
|
||||
IsManager: p.IsManager,
|
||||
IsFreeAgent: p.IsFreeAgent,
|
||||
})
|
||||
}
|
||||
sort.Slice(scored, func(i, j int) bool {
|
||||
si, sj := 0, 0
|
||||
if scored[i].Stats.Score != nil {
|
||||
si = *scored[i].Stats.Score
|
||||
}
|
||||
if scored[j].Stats.Score != nil {
|
||||
sj = *scored[j].Stats.Score
|
||||
}
|
||||
return si > sj
|
||||
})
|
||||
if len(scored) > limit {
|
||||
scored = scored[:limit]
|
||||
}
|
||||
return scored
|
||||
}
|
||||
|
||||
homeTop := collectTop(rosters["home"], 3)
|
||||
awayTop := collectTop(rosters["away"], 3)
|
||||
}}
|
||||
if len(homeTop) > 0 || len(awayTop) > 0 {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Top Performers</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Home Top Performers -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
if fixture.HomeTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
<h3 class="text-md font-bold text-text">{ fixture.HomeTeam.Name }</h3>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
for i, p := range homeTop {
|
||||
@topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Away Top Performers -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
if fixture.AwayTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
<h3 class="text-md font-bold text-text">{ fixture.AwayTeam.Name }</h3>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
for i, p := range awayTop {
|
||||
@topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// topPerformerCard renders a single top performer card with key stats.
|
||||
templ topPerformerCard(player *db.Player, stats *db.FixtureResultPlayerStats, isManager bool, isFreeAgent bool, rank int) {
|
||||
{{
|
||||
rankLabels := map[int]string{1: "🥇", 2: "🥈", 3: "🥉"}
|
||||
rankLabel := rankLabels[rank]
|
||||
}}
|
||||
<div class="flex items-center gap-3 px-4 py-3 bg-surface0 border border-surface1 rounded-lg">
|
||||
<span class="text-lg shrink-0">{ rankLabel }</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium truncate">
|
||||
@links.PlayerLink(player)
|
||||
</span>
|
||||
if isManager {
|
||||
<span class="px-1 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium shrink-0">
|
||||
★
|
||||
</span>
|
||||
}
|
||||
if isFreeAgent {
|
||||
<span class="px-1 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium shrink-0">
|
||||
FA
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-1 text-xs text-subtext0">
|
||||
if stats.Score != nil {
|
||||
<span title="Score"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Score) }</span> SC</span>
|
||||
}
|
||||
if stats.Goals != nil {
|
||||
<span title="Goals"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Goals) }</span> G</span>
|
||||
}
|
||||
if stats.Assists != nil {
|
||||
<span title="Assists"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Assists) }</span> A</span>
|
||||
}
|
||||
if stats.Saves != nil {
|
||||
<span title="Saves"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Saves) }</span> SV</span>
|
||||
}
|
||||
if stats.Shots != nil {
|
||||
<span title="Shots"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Shots) }</span> SH</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// analysisStandingsContext shows how this result fits into the league standings.
|
||||
templ analysisStandingsContext(fixture *db.Fixture, preview *db.MatchPreviewData) {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">League Context</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- Team Name Headers -->
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-1 text-right pr-4">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
if fixture.HomeTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
<span class="text-sm font-bold text-text">{ fixture.HomeTeam.ShortName }</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-28 sm:w-36 text-center shrink-0"></div>
|
||||
<div class="flex-1 text-left pl-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-text">{ fixture.AwayTeam.ShortName }</span>
|
||||
if fixture.AwayTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-0">
|
||||
{{
|
||||
homePos := ordinal(preview.HomePosition)
|
||||
awayPos := ordinal(preview.AwayPosition)
|
||||
if preview.HomePosition == 0 {
|
||||
homePos = "N/A"
|
||||
}
|
||||
if preview.AwayPosition == 0 {
|
||||
awayPos = "N/A"
|
||||
}
|
||||
}}
|
||||
@previewStatRow(
|
||||
homePos,
|
||||
"Position",
|
||||
awayPos,
|
||||
preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition,
|
||||
preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.Points),
|
||||
"Points",
|
||||
fmt.Sprint(preview.AwayRecord.Points),
|
||||
preview.HomeRecord.Points > preview.AwayRecord.Points,
|
||||
preview.AwayRecord.Points > preview.HomeRecord.Points,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprintf("%d-%d-%d-%d",
|
||||
preview.HomeRecord.Wins,
|
||||
preview.HomeRecord.OvertimeWins,
|
||||
preview.HomeRecord.OvertimeLosses,
|
||||
preview.HomeRecord.Losses,
|
||||
),
|
||||
"Record",
|
||||
fmt.Sprintf("%d-%d-%d-%d",
|
||||
preview.AwayRecord.Wins,
|
||||
preview.AwayRecord.OvertimeWins,
|
||||
preview.AwayRecord.OvertimeLosses,
|
||||
preview.AwayRecord.Losses,
|
||||
),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
{{
|
||||
homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst
|
||||
awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst
|
||||
}}
|
||||
@previewStatRow(
|
||||
fmt.Sprintf("%+d", homeDiff),
|
||||
"Goal Diff",
|
||||
fmt.Sprintf("%+d", awayDiff),
|
||||
homeDiff > awayDiff,
|
||||
awayDiff > homeDiff,
|
||||
)
|
||||
<!-- Recent Form -->
|
||||
if len(preview.HomeRecentGames) > 0 || len(preview.AwayRecentGames) > 0 {
|
||||
<div class="flex items-center py-3 border-b border-surface1 last:border-b-0">
|
||||
<div class="flex-1 flex justify-end pr-4">
|
||||
<div class="flex items-center gap-1">
|
||||
for _, g := range preview.HomeRecentGames {
|
||||
@gameOutcomeIcon(g)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-28 sm:w-36 text-center shrink-0">
|
||||
<span class="text-xs font-semibold text-subtext0 uppercase tracking-wider">Form</span>
|
||||
</div>
|
||||
<div class="flex-1 flex pl-4">
|
||||
<div class="flex items-center gap-1">
|
||||
for _, g := range preview.AwayRecentGames {
|
||||
@gameOutcomeIcon(g)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
435
internal/view/seasonsview/fixture_match_preview.templ
Normal file
435
internal/view/seasonsview/fixture_match_preview.templ
Normal file
@@ -0,0 +1,435 @@
|
||||
package seasonsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||
import "fmt"
|
||||
import "sort"
|
||||
|
||||
// fixtureMatchPreviewTab renders the full Match Preview tab content.
|
||||
// Shows team standings comparison, recent form, and full rosters side-by-side.
|
||||
templ fixtureMatchPreviewTab(
|
||||
fixture *db.Fixture,
|
||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||
preview *db.MatchPreviewData,
|
||||
) {
|
||||
<div class="space-y-6">
|
||||
<!-- Team Comparison Header -->
|
||||
@matchPreviewHeader(fixture, preview)
|
||||
|
||||
<!-- Form Guide (Last 5 Games) -->
|
||||
@matchPreviewFormGuide(fixture, preview)
|
||||
|
||||
<!-- Team Rosters -->
|
||||
@matchPreviewRosters(fixture, rosters)
|
||||
</div>
|
||||
}
|
||||
|
||||
// matchPreviewHeader renders the broadcast-style team comparison with standings.
|
||||
templ matchPreviewHeader(fixture *db.Fixture, preview *db.MatchPreviewData) {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Team Comparison</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- Team Names and VS -->
|
||||
<div class="flex items-center justify-center gap-4 sm:gap-8 mb-8">
|
||||
<!-- Home Team -->
|
||||
<div class="flex flex-col items-center text-center flex-1">
|
||||
if fixture.HomeTeam.Color != "" {
|
||||
<div
|
||||
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||
></div>
|
||||
}
|
||||
<h3 class="text-xl sm:text-2xl font-bold text-text">
|
||||
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
|
||||
</h3>
|
||||
<span class="text-subtext0 text-sm font-mono mt-1">{ fixture.HomeTeam.ShortName }</span>
|
||||
</div>
|
||||
<!-- VS Divider -->
|
||||
<div class="flex flex-col items-center shrink-0">
|
||||
<span class="text-3xl sm:text-4xl font-bold text-subtext0">VS</span>
|
||||
</div>
|
||||
<!-- Away Team -->
|
||||
<div class="flex flex-col items-center text-center flex-1">
|
||||
if fixture.AwayTeam.Color != "" {
|
||||
<div
|
||||
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||
></div>
|
||||
}
|
||||
<h3 class="text-xl sm:text-2xl font-bold text-text">
|
||||
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
|
||||
</h3>
|
||||
<span class="text-subtext0 text-sm font-mono mt-1">{ fixture.AwayTeam.ShortName }</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stats Comparison Grid -->
|
||||
{{
|
||||
homePos := ordinal(preview.HomePosition)
|
||||
awayPos := ordinal(preview.AwayPosition)
|
||||
if preview.HomePosition == 0 {
|
||||
homePos = "N/A"
|
||||
}
|
||||
if preview.AwayPosition == 0 {
|
||||
awayPos = "N/A"
|
||||
}
|
||||
}}
|
||||
<div class="space-y-0">
|
||||
<!-- Position -->
|
||||
@previewStatRow(
|
||||
homePos,
|
||||
"Position",
|
||||
awayPos,
|
||||
preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition,
|
||||
preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition,
|
||||
)
|
||||
<!-- Points -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.Points),
|
||||
"Points",
|
||||
fmt.Sprint(preview.AwayRecord.Points),
|
||||
preview.HomeRecord.Points > preview.AwayRecord.Points,
|
||||
preview.AwayRecord.Points > preview.HomeRecord.Points,
|
||||
)
|
||||
<!-- Played -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.Played),
|
||||
"Played",
|
||||
fmt.Sprint(preview.AwayRecord.Played),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
<!-- Wins -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.Wins),
|
||||
"Wins",
|
||||
fmt.Sprint(preview.AwayRecord.Wins),
|
||||
preview.HomeRecord.Wins > preview.AwayRecord.Wins,
|
||||
preview.AwayRecord.Wins > preview.HomeRecord.Wins,
|
||||
)
|
||||
<!-- OT Wins -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.OvertimeWins),
|
||||
"OT Wins",
|
||||
fmt.Sprint(preview.AwayRecord.OvertimeWins),
|
||||
preview.HomeRecord.OvertimeWins > preview.AwayRecord.OvertimeWins,
|
||||
preview.AwayRecord.OvertimeWins > preview.HomeRecord.OvertimeWins,
|
||||
)
|
||||
<!-- OT Losses -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.OvertimeLosses),
|
||||
"OT Losses",
|
||||
fmt.Sprint(preview.AwayRecord.OvertimeLosses),
|
||||
preview.HomeRecord.OvertimeLosses < preview.AwayRecord.OvertimeLosses,
|
||||
preview.AwayRecord.OvertimeLosses < preview.HomeRecord.OvertimeLosses,
|
||||
)
|
||||
<!-- Losses -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.Losses),
|
||||
"Losses",
|
||||
fmt.Sprint(preview.AwayRecord.Losses),
|
||||
preview.HomeRecord.Losses < preview.AwayRecord.Losses,
|
||||
preview.AwayRecord.Losses < preview.HomeRecord.Losses,
|
||||
)
|
||||
<!-- Goals For -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.GoalsFor),
|
||||
"Goals For",
|
||||
fmt.Sprint(preview.AwayRecord.GoalsFor),
|
||||
preview.HomeRecord.GoalsFor > preview.AwayRecord.GoalsFor,
|
||||
preview.AwayRecord.GoalsFor > preview.HomeRecord.GoalsFor,
|
||||
)
|
||||
<!-- Goals Against -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.GoalsAgainst),
|
||||
"Goals Against",
|
||||
fmt.Sprint(preview.AwayRecord.GoalsAgainst),
|
||||
preview.HomeRecord.GoalsAgainst < preview.AwayRecord.GoalsAgainst,
|
||||
preview.AwayRecord.GoalsAgainst < preview.HomeRecord.GoalsAgainst,
|
||||
)
|
||||
<!-- Goal Difference -->
|
||||
{{
|
||||
homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst
|
||||
awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst
|
||||
homeDiffStr := fmt.Sprintf("%+d", homeDiff)
|
||||
awayDiffStr := fmt.Sprintf("%+d", awayDiff)
|
||||
}}
|
||||
@previewStatRow(
|
||||
homeDiffStr,
|
||||
"Goal Diff",
|
||||
awayDiffStr,
|
||||
homeDiff > awayDiff,
|
||||
awayDiff > homeDiff,
|
||||
)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// previewStatRow renders a single comparison stat row in the broadcast-style layout.
|
||||
// The stat label is centered, with home value on the left and away value on the right.
|
||||
// homeHighlight/awayHighlight indicate which side has the better value.
|
||||
templ previewStatRow(homeValue, label, awayValue string, homeHighlight, awayHighlight bool) {
|
||||
<div class="flex items-center py-2.5 border-b border-surface1 last:border-b-0">
|
||||
<!-- Home Value -->
|
||||
<div class="flex-1 text-right pr-4">
|
||||
<span class={ "text-lg font-bold", templ.KV("text-green", homeHighlight), templ.KV("text-text", !homeHighlight) }>
|
||||
{ homeValue }
|
||||
</span>
|
||||
</div>
|
||||
<!-- Label -->
|
||||
<div class="w-28 sm:w-36 text-center shrink-0">
|
||||
<span class="text-xs font-semibold text-subtext0 uppercase tracking-wider">{ label }</span>
|
||||
</div>
|
||||
<!-- Away Value -->
|
||||
<div class="flex-1 text-left pl-4">
|
||||
<span class={ "text-lg font-bold", templ.KV("text-green", awayHighlight), templ.KV("text-text", !awayHighlight) }>
|
||||
{ awayValue }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// matchPreviewFormGuide renders the recent form section with last 5 game outcome icons.
|
||||
templ matchPreviewFormGuide(fixture *db.Fixture, preview *db.MatchPreviewData) {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Recent Form</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- Home Team Form -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
if fixture.HomeTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
<h3 class="text-md font-bold text-text">{ fixture.HomeTeam.Name }</h3>
|
||||
</div>
|
||||
if len(preview.HomeRecentGames) == 0 {
|
||||
<p class="text-subtext1 text-sm">No recent matches played</p>
|
||||
} else {
|
||||
<!-- Outcome Icons: chronological (oldest → newest, left → right) -->
|
||||
<div class="flex items-center gap-1.5 mb-4">
|
||||
for _, g := range preview.HomeRecentGames {
|
||||
@gameOutcomeIcon(g)
|
||||
}
|
||||
</div>
|
||||
<!-- Recent Results List: most recent first -->
|
||||
<div class="space-y-1.5">
|
||||
for i := len(preview.HomeRecentGames) - 1; i >= 0; i-- {
|
||||
@recentGameRow(preview.HomeRecentGames[i])
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Away Team Form -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
if fixture.AwayTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
<h3 class="text-md font-bold text-text">{ fixture.AwayTeam.Name }</h3>
|
||||
</div>
|
||||
if len(preview.AwayRecentGames) == 0 {
|
||||
<p class="text-subtext1 text-sm">No recent matches played</p>
|
||||
} else {
|
||||
<!-- Outcome Icons: chronological (oldest → newest, left → right) -->
|
||||
<div class="flex items-center gap-1.5 mb-4">
|
||||
for _, g := range preview.AwayRecentGames {
|
||||
@gameOutcomeIcon(g)
|
||||
}
|
||||
</div>
|
||||
<!-- Recent Results List: most recent first -->
|
||||
<div class="space-y-1.5">
|
||||
for i := len(preview.AwayRecentGames) - 1; i >= 0; i-- {
|
||||
@recentGameRow(preview.AwayRecentGames[i])
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// outcomeStyle holds the styling info for a game outcome type.
|
||||
type outcomeStyle struct {
|
||||
iconBg string // Background class for the icon badge
|
||||
rowBg string // Background class for the row
|
||||
text string // Text color class
|
||||
label string // Short label (W, L, OW, OL, D, F)
|
||||
fullLabel string // Full label for row display (W, OTW, OTL, L, D, FF)
|
||||
desc string // Human-readable description (Win, Loss, etc.)
|
||||
}
|
||||
|
||||
func getOutcomeStyle(outcomeType string) outcomeStyle {
|
||||
switch outcomeType {
|
||||
case "W":
|
||||
return outcomeStyle{"bg-green/20", "bg-green/10", "text-green", "W", "W", "Win"}
|
||||
case "OTW":
|
||||
return outcomeStyle{"bg-yellow/20", "bg-yellow/10", "text-yellow", "OW", "OTW", "OT Win"}
|
||||
case "OTL":
|
||||
return outcomeStyle{"bg-peach/20", "bg-peach/10", "text-peach", "OL", "OTL", "OT Loss"}
|
||||
case "L":
|
||||
return outcomeStyle{"bg-red/20", "bg-red/10", "text-red", "L", "L", "Loss"}
|
||||
case "D":
|
||||
return outcomeStyle{"bg-overlay0/20", "bg-overlay0/10", "text-overlay0", "D", "D", "Draw"}
|
||||
case "F":
|
||||
return outcomeStyle{"bg-red/30", "bg-red/15", "text-red", "F", "FF", "Forfeit"}
|
||||
default:
|
||||
return outcomeStyle{"bg-surface1", "bg-surface0", "text-subtext0", "?", "?", "Unknown"}
|
||||
}
|
||||
}
|
||||
|
||||
// gameOutcomeIcon renders a single game outcome as a colored badge.
|
||||
templ gameOutcomeIcon(outcome *db.GameOutcome) {
|
||||
{{
|
||||
style := getOutcomeStyle(outcome.Type)
|
||||
tooltip := ""
|
||||
if outcome.Opponent != nil {
|
||||
tooltip = fmt.Sprintf("%s vs %s", style.desc, outcome.Opponent.Name)
|
||||
if outcome.IsForfeit {
|
||||
tooltip += " (Forfeit)"
|
||||
} else if outcome.Score != "" {
|
||||
tooltip += fmt.Sprintf(" (%s)", outcome.Score)
|
||||
}
|
||||
}
|
||||
}}
|
||||
<span
|
||||
class={ "inline-flex items-center justify-center w-9 h-9 rounded-md text-xs font-bold cursor-default", style.iconBg, style.text }
|
||||
title={ tooltip }
|
||||
>
|
||||
{ style.label }
|
||||
</span>
|
||||
}
|
||||
|
||||
// recentGameRow renders a single recent game result as a compact row.
|
||||
templ recentGameRow(outcome *db.GameOutcome) {
|
||||
{{
|
||||
style := getOutcomeStyle(outcome.Type)
|
||||
opponentName := "Unknown"
|
||||
if outcome.Opponent != nil {
|
||||
opponentName = outcome.Opponent.Name
|
||||
}
|
||||
}}
|
||||
<div class={ "flex items-center gap-3 px-3 py-2 rounded-lg", style.rowBg }>
|
||||
<span class={ "text-xs font-bold w-8 text-center", style.text }>{ style.fullLabel }</span>
|
||||
<span class="text-sm text-text">vs { opponentName }</span>
|
||||
if outcome.IsForfeit {
|
||||
<span class="text-xs text-red ml-auto font-medium">Forfeit</span>
|
||||
} else if outcome.Score != "" {
|
||||
<span class="text-sm text-subtext0 ml-auto font-mono">{ outcome.Score }</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// matchPreviewRosters renders team rosters side-by-side for the match preview.
|
||||
templ matchPreviewRosters(
|
||||
fixture *db.Fixture,
|
||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||
) {
|
||||
{{
|
||||
homePlayers := rosters["home"]
|
||||
awayPlayers := rosters["away"]
|
||||
}}
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Team Rosters</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Home Team Roster -->
|
||||
@previewRosterColumn(fixture.HomeTeam, homePlayers, fixture.Season, fixture.League)
|
||||
<!-- Away Team Roster -->
|
||||
@previewRosterColumn(fixture.AwayTeam, awayPlayers, fixture.Season, fixture.League)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// previewRosterColumn renders a single team's roster for the match preview.
|
||||
templ previewRosterColumn(
|
||||
team *db.Team,
|
||||
players []*db.PlayerWithPlayStatus,
|
||||
season *db.Season,
|
||||
league *db.League,
|
||||
) {
|
||||
{{
|
||||
// Separate managers and regular players
|
||||
var managers []*db.PlayerWithPlayStatus
|
||||
var roster []*db.PlayerWithPlayStatus
|
||||
for _, p := range players {
|
||||
if p.IsManager {
|
||||
managers = append(managers, p)
|
||||
} else {
|
||||
roster = append(roster, p)
|
||||
}
|
||||
}
|
||||
// Sort roster alphabetically by display name
|
||||
sort.Slice(roster, func(i, j int) bool {
|
||||
return roster[i].Player.DisplayName() < roster[j].Player.DisplayName()
|
||||
})
|
||||
}}
|
||||
<div>
|
||||
<!-- Team Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
if team.Color != "" {
|
||||
<span
|
||||
class="w-3.5 h-3.5 rounded-full shrink-0 border border-surface1"
|
||||
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
||||
></span>
|
||||
}
|
||||
<h3 class="text-md font-bold">
|
||||
@links.TeamNameLinkInSeason(team, season, league)
|
||||
</h3>
|
||||
</div>
|
||||
<span class="text-xs text-subtext0">
|
||||
{ fmt.Sprint(len(players)) } players
|
||||
</span>
|
||||
</div>
|
||||
if len(players) == 0 {
|
||||
<p class="text-subtext1 text-sm text-center py-4">No players on roster.</p>
|
||||
} else {
|
||||
<div class="space-y-1">
|
||||
<!-- Manager(s) -->
|
||||
for _, p := range managers {
|
||||
<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface0 border border-surface1">
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium shrink-0">
|
||||
★
|
||||
</span>
|
||||
<span class="text-sm font-medium">
|
||||
@links.PlayerLink(p.Player)
|
||||
</span>
|
||||
if p.IsFreeAgent {
|
||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||
FA
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Regular Players -->
|
||||
for _, p := range roster {
|
||||
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface0 transition">
|
||||
<span class="text-sm">
|
||||
@links.PlayerLink(p.Player)
|
||||
</span>
|
||||
if p.IsFreeAgent {
|
||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||
FA
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -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(
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-text mb-1">Review Match Result</h1>
|
||||
<p class="text-sm text-subtext1">
|
||||
{ fixture.HomeTeam.Name } vs { fixture.AwayTeam.Name }
|
||||
<span>
|
||||
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
|
||||
</span>
|
||||
vs
|
||||
<span>
|
||||
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
|
||||
</span>
|
||||
<span class="text-subtext0 ml-1">
|
||||
Round { fmt.Sprint(fixture.Round) }
|
||||
</span>
|
||||
@@ -96,12 +103,16 @@ templ FixtureReviewResultPage(
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-center gap-8 py-4">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-subtext0 mb-1">{ fixture.HomeTeam.Name }</p>
|
||||
<p class="text-sm text-subtext0 mb-1">
|
||||
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
|
||||
</p>
|
||||
<p class="text-4xl font-bold text-text">{ fmt.Sprint(result.HomeScore) }</p>
|
||||
</div>
|
||||
<div class="text-2xl text-subtext0 font-light">—</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-subtext0 mb-1">{ fixture.AwayTeam.Name }</p>
|
||||
<p class="text-sm text-subtext0 mb-1">
|
||||
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
|
||||
</p>
|
||||
<p class="text-4xl font-bold text-text">{ fmt.Sprint(result.AwayScore) }</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,8 +138,8 @@ templ FixtureReviewResultPage(
|
||||
</div>
|
||||
<!-- Player Stats Tables -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
@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)
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
@@ -164,7 +175,7 @@ templ FixtureReviewResultPage(
|
||||
}
|
||||
}
|
||||
|
||||
templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
||||
templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string, season *db.Season, league *db.League) {
|
||||
{{
|
||||
// Collect unique players for this team across all periods
|
||||
// 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)
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
@@ -205,6 +216,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
||||
<thead class="bg-surface0 border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
||||
@@ -217,10 +229,12 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for _, ps := range finalStats {
|
||||
<tr class="hover:bg-surface0 transition-colors">
|
||||
<td class="px-3 py-2 text-sm text-text">
|
||||
<td class="px-3 py-2 text-sm">
|
||||
<span class="flex items-center gap-1.5">
|
||||
{ ps.Username }
|
||||
if ps.PlayerID == nil {
|
||||
if ps.PlayerID != nil {
|
||||
@links.PlayerLinkFromStats(*ps.PlayerID, ps.Username)
|
||||
} else {
|
||||
<span class="text-text">{ ps.Username }</span>
|
||||
<span class="text-yellow text-xs" title="Unmapped player">?</span>
|
||||
}
|
||||
if ps.Stats.IsFreeAgent {
|
||||
@@ -230,6 +244,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ intPtrStr(ps.Stats.PeriodsPlayed) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Goals) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Assists) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Saves) }</td>
|
||||
@@ -241,7 +256,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
||||
}
|
||||
if len(finalStats) == 0 {
|
||||
<tr>
|
||||
<td colspan="8" class="px-3 py-4 text-center text-sm text-subtext1">
|
||||
<td colspan="9" class="px-3 py-4 text-center text-sm text-subtext1">
|
||||
No player stats recorded
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Player</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Registered By</th>
|
||||
if canRemove {
|
||||
<th class="px-4 py-3 text-right text-sm font-semibold text-text">Actions</th>
|
||||
}
|
||||
@@ -53,19 +53,14 @@ templ SeasonLeagueFreeAgents(season *db.Season, league *db.League, freeAgents []
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for _, fa := range freeAgents {
|
||||
<tr class="hover:bg-surface1 transition-colors">
|
||||
<td class="px-4 py-3 text-sm text-text">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="flex items-center gap-2">
|
||||
{ fa.Player.DisplayName() }
|
||||
@links.PlayerLink(fa.Player)
|
||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||
FREE AGENT
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-subtext0">
|
||||
if fa.RegisteredBy != nil {
|
||||
{ fa.RegisteredBy.Username }
|
||||
}
|
||||
</td>
|
||||
if canRemove {
|
||||
<td class="px-4 py-3 text-right">
|
||||
<form
|
||||
|
||||
@@ -1,15 +1,321 @@
|
||||
package seasonsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||
import "fmt"
|
||||
|
||||
templ SeasonLeagueStatsPage(season *db.Season, league *db.League) {
|
||||
templ SeasonLeagueStatsPage(
|
||||
season *db.Season,
|
||||
league *db.League,
|
||||
topGoals []*db.LeagueTopGoalScorer,
|
||||
topAssists []*db.LeagueTopAssister,
|
||||
topSaves []*db.LeagueTopSaver,
|
||||
allStats []*db.LeaguePlayerStats,
|
||||
) {
|
||||
@SeasonLeagueLayout("stats", season, league) {
|
||||
@SeasonLeagueStats()
|
||||
@SeasonLeagueStats(season, league, topGoals, topAssists, topSaves, allStats)
|
||||
}
|
||||
}
|
||||
|
||||
templ SeasonLeagueStats() {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">Coming Soon...</p>
|
||||
templ SeasonLeagueStats(
|
||||
season *db.Season,
|
||||
league *db.League,
|
||||
topGoals []*db.LeagueTopGoalScorer,
|
||||
topAssists []*db.LeagueTopAssister,
|
||||
topSaves []*db.LeagueTopSaver,
|
||||
allStats []*db.LeaguePlayerStats,
|
||||
) {
|
||||
<script src="/static/js/sortable-table.js"></script>
|
||||
if len(topGoals) == 0 && len(topAssists) == 0 && len(topSaves) == 0 && len(allStats) == 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No stats available yet.</p>
|
||||
<p class="text-subtext1 text-sm mt-2">Player statistics will appear here once games are finalized.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="space-y-8">
|
||||
<!-- Trophy Leaders Section -->
|
||||
if len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 {
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-bold text-text text-center">Trophy Leaders</h2>
|
||||
<!-- Triangle layout: two side-by-side on wide screens, saves centered below -->
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<!-- Top row: Goals and Assists side by side when room allows -->
|
||||
<div class="flex flex-col lg:flex-row gap-6 justify-center items-center lg:items-start">
|
||||
@topGoalScorersTable(season, league, topGoals)
|
||||
@topAssistersTable(season, league, topAssists)
|
||||
</div>
|
||||
<!-- Bottom row: Saves centered -->
|
||||
@topSaversTable(season, league, topSaves)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- All Stats Section -->
|
||||
if len(allStats) > 0 {
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-bold text-text text-center">All Stats</h2>
|
||||
@allStatsTable(season, league, allStats)
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.LeagueTopGoalScorer) {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-mantle border-b border-surface1 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-text">
|
||||
Top Goal Scorers
|
||||
</h3>
|
||||
</div>
|
||||
<!-- Sorting key -->
|
||||
<div class="bg-mantle border-b border-surface1 px-4 py-1.5 flex items-center gap-3 text-xs text-subtext0">
|
||||
<span class="font-semibold text-subtext1">Sort:</span>
|
||||
<span>G ↓</span>
|
||||
<span>PP ↑</span>
|
||||
<span>SH ↑</span>
|
||||
</div>
|
||||
if len(goals) == 0 {
|
||||
<div class="p-6 text-center">
|
||||
<p class="text-subtext0 text-sm">No goal data available yet.</p>
|
||||
</div>
|
||||
} else {
|
||||
<table>
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Team</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-blue" title="Goals">G</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for i, gs := range goals {
|
||||
<tr class="hover:bg-surface1 transition-colors">
|
||||
<td class="px-3 py-2 text-center text-sm font-medium text-subtext0">
|
||||
{ fmt.Sprint(i + 1) }
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm font-medium whitespace-nowrap">
|
||||
@links.PlayerLinkFromStats(gs.PlayerID, gs.PlayerName)
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm whitespace-nowrap">
|
||||
@teamColorName(gs.TeamID, gs.TeamName, gs.TeamColor, season, league)
|
||||
</td>
|
||||
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(gs.Goals) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(gs.PeriodsPlayed) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(gs.Shots) }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ topAssistersTable(season *db.Season, league *db.League, assists []*db.LeagueTopAssister) {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-mantle border-b border-surface1 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-text">
|
||||
Top Assisters
|
||||
</h3>
|
||||
</div>
|
||||
<!-- Sorting key -->
|
||||
<div class="bg-mantle border-b border-surface1 px-4 py-1.5 flex items-center gap-3 text-xs text-subtext0">
|
||||
<span class="font-semibold text-subtext1">Sort:</span>
|
||||
<span>A ↓</span>
|
||||
<span>PP ↑</span>
|
||||
<span>PA ↓</span>
|
||||
</div>
|
||||
if len(assists) == 0 {
|
||||
<div class="p-6 text-center">
|
||||
<p class="text-subtext0 text-sm">No assist data available yet.</p>
|
||||
</div>
|
||||
} else {
|
||||
<table>
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Team</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-blue" title="Assists">A</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Primary Assists">PA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for i, as := range assists {
|
||||
<tr class="hover:bg-surface1 transition-colors">
|
||||
<td class="px-3 py-2 text-center text-sm font-medium text-subtext0">
|
||||
{ fmt.Sprint(i + 1) }
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm font-medium whitespace-nowrap">
|
||||
@links.PlayerLinkFromStats(as.PlayerID, as.PlayerName)
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm whitespace-nowrap">
|
||||
@teamColorName(as.TeamID, as.TeamName, as.TeamColor, season, league)
|
||||
</td>
|
||||
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(as.Assists) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(as.PeriodsPlayed) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(as.PrimaryAssists) }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTopSaver) {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-mantle border-b border-surface1 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-text">
|
||||
Top Saves
|
||||
</h3>
|
||||
</div>
|
||||
<!-- Sorting key -->
|
||||
<div class="bg-mantle border-b border-surface1 px-4 py-1.5 flex items-center gap-3 text-xs text-subtext0">
|
||||
<span class="font-semibold text-subtext1">Sort:</span>
|
||||
<span>SV ↓</span>
|
||||
<span>PP ↑</span>
|
||||
<span>BLK ↓</span>
|
||||
</div>
|
||||
if len(saves) == 0 {
|
||||
<div class="p-6 text-center">
|
||||
<p class="text-subtext0 text-sm">No save data available yet.</p>
|
||||
</div>
|
||||
} else {
|
||||
<table>
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Team</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-blue" title="Saves">SV</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BLK</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for i, sv := range saves {
|
||||
<tr class="hover:bg-surface1 transition-colors">
|
||||
<td class="px-3 py-2 text-center text-sm font-medium text-subtext0">
|
||||
{ fmt.Sprint(i + 1) }
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm font-medium whitespace-nowrap">
|
||||
@links.PlayerLinkFromStats(sv.PlayerID, sv.PlayerName)
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm whitespace-nowrap">
|
||||
@teamColorName(sv.TeamID, sv.TeamName, sv.TeamColor, season, league)
|
||||
</td>
|
||||
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(sv.Saves) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(sv.PeriodsPlayed) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(sv.Blocks) }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ allStatsTable(season *db.Season, league *db.League, allStats []*db.LeaguePlayerStats) {
|
||||
<div
|
||||
class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"
|
||||
x-data="sortableTable('score', 'desc')"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Team</th>
|
||||
@sortableCol("gp", "GP", "Games Played")
|
||||
@sortableCol("pp", "PP", "Periods Played")
|
||||
@sortableCol("score", "SC", "Score")
|
||||
@sortableCol("goals", "G", "Goals")
|
||||
@sortableCol("assists", "A", "Assists")
|
||||
@sortableCol("pa", "PA", "Primary Assists")
|
||||
@sortableCol("sa", "SA", "Secondary Assists")
|
||||
@sortableCol("saves", "SV", "Saves")
|
||||
@sortableCol("shots", "SH", "Shots")
|
||||
@sortableCol("blocks", "BLK", "Blocks")
|
||||
@sortableCol("passes", "PAS", "Passes")
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1" x-ref="tbody">
|
||||
for _, ps := range allStats {
|
||||
<tr
|
||||
class="hover:bg-surface1 transition-colors"
|
||||
data-name={ ps.PlayerName }
|
||||
data-team={ ps.TeamName }
|
||||
data-gp={ fmt.Sprint(ps.GamesPlayed) }
|
||||
data-pp={ fmt.Sprint(ps.PeriodsPlayed) }
|
||||
data-score={ fmt.Sprint(ps.Score) }
|
||||
data-goals={ fmt.Sprint(ps.Goals) }
|
||||
data-assists={ fmt.Sprint(ps.Assists) }
|
||||
data-pa={ fmt.Sprint(ps.PrimaryAssists) }
|
||||
data-sa={ fmt.Sprint(ps.SecondaryAssists) }
|
||||
data-saves={ fmt.Sprint(ps.Saves) }
|
||||
data-shots={ fmt.Sprint(ps.Shots) }
|
||||
data-blocks={ fmt.Sprint(ps.Blocks) }
|
||||
data-passes={ fmt.Sprint(ps.Passes) }
|
||||
>
|
||||
<td class="px-3 py-2 text-sm font-medium">
|
||||
@links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName)
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm">
|
||||
@teamColorName(ps.TeamID, ps.TeamName, ps.TeamColor, season, league)
|
||||
</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.GamesPlayed) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.PeriodsPlayed) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text font-medium">{ fmt.Sprint(ps.Score) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Goals) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Assists) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.PrimaryAssists) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.SecondaryAssists) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Saves) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.Shots) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.Blocks) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.Passes) }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ sortableCol(field string, label string, title string) {
|
||||
<th
|
||||
class="px-2 py-2 text-center text-xs font-semibold text-text select-none hover:cursor-pointer hover:text-blue transition-colors"
|
||||
title={ title }
|
||||
@click={ fmt.Sprintf("sort('%s')", field) }
|
||||
>
|
||||
<span class="inline-flex items-center gap-0.5">
|
||||
{ label }
|
||||
<template x-if={ fmt.Sprintf("sortField === '%s'", field) }>
|
||||
<span class="text-blue" x-text={ "sortDir === 'asc' ? '↑' : '↓'" }></span>
|
||||
</template>
|
||||
</span>
|
||||
</th>
|
||||
}
|
||||
|
||||
templ teamColorName(teamID int, teamName string, teamColor string, season *db.Season, league *db.League) {
|
||||
if teamID > 0 && teamName != "" {
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams/%d", season.ShortName, league.ShortName, teamID)) }
|
||||
class="flex items-center gap-2 hover:text-blue transition"
|
||||
>
|
||||
if teamColor != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(teamColor) }
|
||||
></span>
|
||||
}
|
||||
<span class="text-sm font-medium whitespace-nowrap">{ teamName }</span>
|
||||
</a>
|
||||
} else {
|
||||
<span class="text-sm text-subtext0 italic">—</span>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No teams in this league yet.</p>
|
||||
@@ -43,7 +44,7 @@ templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) {
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for _, entry := range leaderboard {
|
||||
@leaderboardRow(entry)
|
||||
@leaderboardRow(entry, season, league)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -52,7 +53,7 @@ templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
templ leaderboardRow(entry *db.LeaderboardEntry) {
|
||||
templ leaderboardRow(entry *db.LeaderboardEntry, season *db.Season, league *db.League) {
|
||||
{{
|
||||
r := entry.Record
|
||||
goalDiff := r.GoalsFor - r.GoalsAgainst
|
||||
@@ -68,15 +69,7 @@ templ leaderboardRow(entry *db.LeaderboardEntry) {
|
||||
{ fmt.Sprint(entry.Position) }
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
if entry.Team.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(entry.Team.Color) }
|
||||
></span>
|
||||
}
|
||||
<span class="text-sm font-medium text-text">{ entry.Team.Name }</span>
|
||||
</div>
|
||||
@links.TeamLinkInSeason(entry.Team, season, league)
|
||||
</td>
|
||||
<td class="px-3 py-3 text-center text-sm text-subtext0">
|
||||
{ fmt.Sprint(r.Played) }
|
||||
|
||||
@@ -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,
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName)) }
|
||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||
bg-surface1 hover:bg-surface2 text-text transition"
|
||||
>
|
||||
Back to Teams
|
||||
</a>
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/teams/%d", team.ID)) }
|
||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||
bg-surface0 border border-surface1 hover:bg-surface1 text-subtext0 hover:text-text transition text-sm"
|
||||
>
|
||||
View All Seasons
|
||||
</a>
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName)) }
|
||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||
bg-surface1 hover:bg-surface2 text-text transition"
|
||||
>
|
||||
Back to Teams
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="bg-crust p-6">
|
||||
<!-- Top row: Roster (left) + Fixtures (right) -->
|
||||
{{
|
||||
// Split fixtures into upcoming and completed
|
||||
var upcoming []*db.Fixture
|
||||
var completed []*db.Fixture
|
||||
for _, f := range fixtures {
|
||||
if _, hasResult := resultMap[f.ID]; hasResult {
|
||||
completed = append(completed, f)
|
||||
} else {
|
||||
upcoming = append(upcoming, f)
|
||||
}
|
||||
}
|
||||
// Sort completed by scheduled time descending (most recent first)
|
||||
sort.Slice(completed, func(i, j int) bool {
|
||||
ti := time.Time{}
|
||||
tj := time.Time{}
|
||||
if si, ok := scheduleMap[completed[i].ID]; ok && si.ScheduledTime != nil {
|
||||
ti = *si.ScheduledTime
|
||||
}
|
||||
if sj, ok := scheduleMap[completed[j].ID]; ok && sj.ScheduledTime != nil {
|
||||
tj = *sj.ScheduledTime
|
||||
}
|
||||
return ti.After(tj)
|
||||
})
|
||||
// Limit to 5 most recent results
|
||||
recentResults := completed
|
||||
if len(recentResults) > 5 {
|
||||
recentResults = recentResults[:5]
|
||||
}
|
||||
}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Top Left: Team Standing -->
|
||||
@teamRecordCard(record, position, totalTeams)
|
||||
<!-- Top Right: Results -->
|
||||
@teamResultsSection(twr.Team, recentResults, resultMap)
|
||||
<!-- Bottom Left: Roster -->
|
||||
@TeamRosterSection(twr, available)
|
||||
@teamFixturesPane(twr.Team, fixtures, scheduleMap, resultMap)
|
||||
<!-- Bottom Right: Upcoming -->
|
||||
@teamUpcomingSection(twr.Team, upcoming, scheduleMap)
|
||||
</div>
|
||||
<!-- Stats below both -->
|
||||
<!-- Player Stats (full width) -->
|
||||
<div class="mt-6">
|
||||
@teamStatsSection(record, playerStats)
|
||||
@playerStatsSection(playerStats)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,7 +155,9 @@ templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||
if twr.Manager != nil {
|
||||
<div class="px-4 py-3 flex items-center justify-between">
|
||||
<span class="text-text font-medium">{ twr.Manager.Name }</span>
|
||||
<span class="font-medium">
|
||||
@links.PlayerLink(twr.Manager)
|
||||
</span>
|
||||
<span class="text-xs px-2 py-0.5 bg-yellow/20 text-yellow rounded font-medium">
|
||||
★ Manager
|
||||
</span>
|
||||
@@ -119,7 +165,7 @@ templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) {
|
||||
}
|
||||
for _, player := range rosterPlayers {
|
||||
<div class="px-4 py-3">
|
||||
<span class="text-text">{ player.Name }</span>
|
||||
@links.PlayerLink(player)
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -396,68 +442,45 @@ templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPl
|
||||
</script>
|
||||
}
|
||||
|
||||
templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) {
|
||||
{{
|
||||
// Split fixtures into upcoming and completed
|
||||
var upcoming []*db.Fixture
|
||||
var completed []*db.Fixture
|
||||
for _, f := range fixtures {
|
||||
if _, hasResult := resultMap[f.ID]; hasResult {
|
||||
completed = append(completed, f)
|
||||
} else {
|
||||
upcoming = append(upcoming, f)
|
||||
}
|
||||
}
|
||||
// Sort completed by scheduled time descending (most recent first)
|
||||
sort.Slice(completed, func(i, j int) bool {
|
||||
ti := time.Time{}
|
||||
tj := time.Time{}
|
||||
if si, ok := scheduleMap[completed[i].ID]; ok && si.ScheduledTime != nil {
|
||||
ti = *si.ScheduledTime
|
||||
}
|
||||
if sj, ok := scheduleMap[completed[j].ID]; ok && sj.ScheduledTime != nil {
|
||||
tj = *sj.ScheduledTime
|
||||
}
|
||||
return ti.After(tj)
|
||||
})
|
||||
// Limit to 5 most recent results
|
||||
recentResults := completed
|
||||
if len(recentResults) > 5 {
|
||||
recentResults = recentResults[:5]
|
||||
}
|
||||
}}
|
||||
<section class="space-y-6">
|
||||
<!-- Results -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-text mb-4">Results</h2>
|
||||
if len(recentResults) == 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No results yet.</p>
|
||||
<p class="text-subtext1 text-sm mt-2">Match results will appear here once games are played.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||
for _, fixture := range recentResults {
|
||||
@teamResultRow(team, fixture, resultMap)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
templ teamResultsSection(team *db.Team, recentResults []*db.Fixture, resultMap map[int]*db.FixtureResult) {
|
||||
<section>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold text-text">
|
||||
Results
|
||||
<span class="text-sm font-normal text-subtext0">(last 5)</span>
|
||||
</h2>
|
||||
</div>
|
||||
<!-- Upcoming -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-text mb-4">Upcoming</h2>
|
||||
if len(upcoming) == 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No upcoming fixtures.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||
for _, fixture := range upcoming {
|
||||
@teamFixtureRow(team, fixture, scheduleMap)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if len(recentResults) == 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No results yet.</p>
|
||||
<p class="text-subtext1 text-sm mt-2">Match results will appear here once games are played.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||
for _, fixture := range recentResults {
|
||||
@teamResultRow(team, fixture, resultMap)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
templ teamUpcomingSection(team *db.Team, upcoming []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
|
||||
<section>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold text-text">Upcoming</h2>
|
||||
</div>
|
||||
if len(upcoming) == 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No upcoming fixtures.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||
for _, fixture := range upcoming {
|
||||
@teamFixtureRow(team, fixture, scheduleMap)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -586,65 +609,98 @@ templ teamResultRow(team *db.Team, fixture *db.Fixture, resultMap map[int]*db.Fi
|
||||
</a>
|
||||
}
|
||||
|
||||
templ teamStatsSection(record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) {
|
||||
templ teamRecordCard(record *db.TeamRecord, position int, totalTeams int) {
|
||||
<section>
|
||||
<div class="mb-4">
|
||||
<h2 class="text-2xl font-bold text-text">Stats</h2>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold text-text">Standing</h2>
|
||||
</div>
|
||||
if record.Played == 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No stats yet.</p>
|
||||
<p class="text-subtext1 text-sm mt-2">Team statistics will appear here once games are played.</p>
|
||||
<p class="text-subtext0 text-lg">No games played yet.</p>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Team Record Summary -->
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden mb-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 divide-x divide-surface1">
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<!-- Position & Points Header -->
|
||||
<div class="flex items-center justify-between px-6 py-5 border-b border-surface1">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-4xl font-bold text-text">{ ordinal(position) }</span>
|
||||
<div>
|
||||
<p class="text-xs text-subtext0 uppercase font-medium">Position</p>
|
||||
<p class="text-sm text-subtext1">of { fmt.Sprint(totalTeams) } teams</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-subtext0 uppercase font-medium">Points</p>
|
||||
<p class="text-3xl font-bold text-blue">{ fmt.Sprint(record.Points) }</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Record Grid -->
|
||||
<div class="grid grid-cols-4 divide-x divide-surface1">
|
||||
@statCell("W", fmt.Sprint(record.Wins), "text-green")
|
||||
@statCell("OTW", fmt.Sprint(record.OvertimeWins), "text-teal")
|
||||
@statCell("OTL", fmt.Sprint(record.OvertimeLosses), "text-peach")
|
||||
@statCell("L", fmt.Sprint(record.Losses), "text-red")
|
||||
</div>
|
||||
<!-- Goals Row -->
|
||||
<div class="grid grid-cols-3 divide-x divide-surface1 border-t border-surface1">
|
||||
@statCell("Played", fmt.Sprint(record.Played), "")
|
||||
@statCell("Record", fmt.Sprintf("%d-%d-%d", record.Wins, record.Losses, record.Draws), "")
|
||||
@statCell("Wins", fmt.Sprint(record.Wins), "text-green")
|
||||
@statCell("Losses", fmt.Sprint(record.Losses), "text-red")
|
||||
@statCell("GF", fmt.Sprint(record.GoalsFor), "")
|
||||
@statCell("GA", fmt.Sprint(record.GoalsAgainst), "")
|
||||
</div>
|
||||
</div>
|
||||
<!-- Player Stats Leaderboard -->
|
||||
if len(playerStats) > 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Games Played">GP</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
templ playerStatsSection(playerStats []*db.AggregatedPlayerStats) {
|
||||
<section>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold text-text">Player Stats</h2>
|
||||
</div>
|
||||
if len(playerStats) == 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No player stats yet.</p>
|
||||
<p class="text-subtext1 text-sm mt-2">Player statistics will appear here once games are played.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Games Played">GP</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for _, ps := range playerStats {
|
||||
<tr class="hover:bg-surface1 transition-colors">
|
||||
<td class="px-3 py-2 text-sm">
|
||||
@links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName)
|
||||
</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.GamesPlayed) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.PeriodsPlayed) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ fmt.Sprint(ps.Score) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Goals) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Assists) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Saves) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Shots) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Blocks) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Passes) }</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for _, ps := range playerStats {
|
||||
<tr class="hover:bg-surface1 transition-colors">
|
||||
<td class="px-3 py-2 text-sm text-text">{ ps.PlayerName }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.GamesPlayed) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ fmt.Sprint(ps.Score) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Goals) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Assists) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Saves) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Shots) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Blocks) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Passes) }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
81
internal/view/teamsview/detail_page.templ
Normal file
81
internal/view/teamsview/detail_page.templ
Normal file
@@ -0,0 +1,81 @@
|
||||
package teamsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||
import "fmt"
|
||||
|
||||
templ DetailPage(team *db.Team, seasonInfos []*db.TeamSeasonInfo, playerStats []*db.TeamAllTimePlayerStats, activeTab string) {
|
||||
@baseview.Layout(team.Name) {
|
||||
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
if team.Color != "" {
|
||||
<div
|
||||
class="w-12 h-12 rounded-full border-2 border-surface1 shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
||||
></div>
|
||||
}
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-text">{ team.Name }</h1>
|
||||
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
||||
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||||
{ team.ShortName }
|
||||
</span>
|
||||
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||||
{ team.AltShortName }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/teams"
|
||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||
bg-surface1 hover:bg-surface2 text-text transition"
|
||||
>
|
||||
Back to Teams
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="bg-surface0 border-b border-surface1">
|
||||
<ul class="flex flex-wrap">
|
||||
@teamDetailTab("seasons", "Seasons", activeTab, team)
|
||||
@teamDetailTab("stats", "Player Stats", activeTab, team)
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<!-- Tab Content -->
|
||||
<div class="mt-6">
|
||||
if activeTab == "seasons" {
|
||||
@TeamDetailSeasons(team, seasonInfos)
|
||||
} else if activeTab == "stats" {
|
||||
@TeamDetailPlayerStats(playerStats)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ teamDetailTab(section string, label string, activeTab string, team *db.Team) {
|
||||
{{
|
||||
isActive := section == activeTab
|
||||
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
|
||||
activeClasses := "border-blue text-blue font-semibold"
|
||||
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
||||
url := fmt.Sprintf("/teams/%d", team.ID)
|
||||
if section != "seasons" {
|
||||
url = fmt.Sprintf("/teams/%d?tab=%s", team.ID, section)
|
||||
}
|
||||
}}
|
||||
<li class="inline-block">
|
||||
<a
|
||||
href={ templ.SafeURL(url) }
|
||||
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||||
>
|
||||
{ label }
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
130
internal/view/teamsview/detail_player_stats.templ
Normal file
130
internal/view/teamsview/detail_player_stats.templ
Normal file
@@ -0,0 +1,130 @@
|
||||
package teamsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||
import "fmt"
|
||||
import "sort"
|
||||
|
||||
templ TeamDetailPlayerStats(playerStats []*db.TeamAllTimePlayerStats) {
|
||||
if len(playerStats) == 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No player stats yet.</p>
|
||||
<p class="text-subtext1 text-sm mt-2">Player statistics will appear here once games are played.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div x-data="{ activeView: 'goals' }">
|
||||
<!-- Sub-view Tabs -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button
|
||||
@click="activeView = 'goals'"
|
||||
:class="activeView === 'goals'
|
||||
? 'bg-blue text-mantle'
|
||||
: 'bg-surface0 border border-surface1 text-subtext0 hover:text-text hover:bg-surface1'"
|
||||
class="px-4 py-2 rounded-lg font-medium text-sm transition hover:cursor-pointer"
|
||||
>
|
||||
Goals
|
||||
</button>
|
||||
<button
|
||||
@click="activeView = 'assists'"
|
||||
:class="activeView === 'assists'
|
||||
? 'bg-blue text-mantle'
|
||||
: 'bg-surface0 border border-surface1 text-subtext0 hover:text-text hover:bg-surface1'"
|
||||
class="px-4 py-2 rounded-lg font-medium text-sm transition hover:cursor-pointer"
|
||||
>
|
||||
Assists
|
||||
</button>
|
||||
<button
|
||||
@click="activeView = 'saves'"
|
||||
:class="activeView === 'saves'
|
||||
? 'bg-blue text-mantle'
|
||||
: 'bg-surface0 border border-surface1 text-subtext0 hover:text-text hover:bg-surface1'"
|
||||
class="px-4 py-2 rounded-lg font-medium text-sm transition hover:cursor-pointer"
|
||||
>
|
||||
Saves
|
||||
</button>
|
||||
</div>
|
||||
<!-- Goals View -->
|
||||
<div x-show="activeView === 'goals'">
|
||||
@playerStatsTable(playerStats, "goals")
|
||||
</div>
|
||||
<!-- Assists View -->
|
||||
<div x-show="activeView === 'assists'" style="display: none;">
|
||||
@playerStatsTable(playerStats, "assists")
|
||||
</div>
|
||||
<!-- Saves View -->
|
||||
<div x-show="activeView === 'saves'" style="display: none;">
|
||||
@playerStatsTable(playerStats, "saves")
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ playerStatsTable(playerStats []*db.TeamAllTimePlayerStats, statType string) {
|
||||
{{
|
||||
// Make a copy so sorting doesn't affect other views
|
||||
sorted := make([]*db.TeamAllTimePlayerStats, len(playerStats))
|
||||
copy(sorted, playerStats)
|
||||
|
||||
switch statType {
|
||||
case "goals":
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Goals > sorted[j].Goals
|
||||
})
|
||||
case "assists":
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Assists > sorted[j].Assists
|
||||
})
|
||||
case "saves":
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Saves > sorted[j].Saves
|
||||
})
|
||||
}
|
||||
|
||||
statLabel := "Goals"
|
||||
statShort := "G"
|
||||
if statType == "assists" {
|
||||
statLabel = "Assists"
|
||||
statShort = "A"
|
||||
} else if statType == "saves" {
|
||||
statLabel = "Saves"
|
||||
statShort = "SV"
|
||||
}
|
||||
_ = statLabel
|
||||
}}
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Seasons Played">SZN</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-blue" title={ statLabel }>{ statShort }</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for i, ps := range sorted {
|
||||
<tr class="hover:bg-surface1 transition-colors">
|
||||
<td class="px-3 py-2 text-center text-sm font-medium text-subtext0">
|
||||
{ fmt.Sprint(i + 1) }
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm font-medium">
|
||||
@links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName)
|
||||
</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.SeasonsPlayed) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.PeriodsPlayed) }</td>
|
||||
if statType == "goals" {
|
||||
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(ps.Goals) }</td>
|
||||
} else if statType == "assists" {
|
||||
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(ps.Assists) }</td>
|
||||
} else {
|
||||
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(ps.Saves) }</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
103
internal/view/teamsview/detail_seasons.templ
Normal file
103
internal/view/teamsview/detail_seasons.templ
Normal file
@@ -0,0 +1,103 @@
|
||||
package teamsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||
import "fmt"
|
||||
|
||||
templ TeamDetailSeasons(team *db.Team, seasonInfos []*db.TeamSeasonInfo) {
|
||||
if len(seasonInfos) == 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No season history yet.</p>
|
||||
<p class="text-subtext1 text-sm mt-2">This team has not participated in any seasons.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
for _, info := range seasonInfos {
|
||||
@teamSeasonCard(team, info)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ teamSeasonCard(team *db.Team, info *db.TeamSeasonInfo) {
|
||||
{{
|
||||
detailURL := fmt.Sprintf(
|
||||
"/seasons/%s/leagues/%s/teams/%d",
|
||||
info.Season.ShortName, info.League.ShortName, team.ID,
|
||||
)
|
||||
}}
|
||||
<a
|
||||
href={ templ.SafeURL(detailURL) }
|
||||
class="bg-mantle border border-surface1 rounded-lg overflow-hidden
|
||||
hover:bg-surface0 transition hover:cursor-pointer block"
|
||||
>
|
||||
<!-- Card Header -->
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-bold text-text">{ info.Season.Name }</h3>
|
||||
<span class="text-subtext0 text-sm">—</span>
|
||||
<span class="text-subtext0 text-sm">{ info.League.Name }</span>
|
||||
</div>
|
||||
@seasonsview.StatusBadge(info.Season, true, true)
|
||||
</div>
|
||||
<!-- Card Body -->
|
||||
<div class="p-4">
|
||||
<!-- Position & Points Row -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Position Badge -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-subtext0 uppercase font-medium">Position</span>
|
||||
<span class="text-2xl font-bold text-text">
|
||||
{ ordinal(info.Position) }
|
||||
</span>
|
||||
<span class="text-sm text-subtext0">
|
||||
/ { fmt.Sprint(info.TotalTeams) }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Points -->
|
||||
<div class="text-right">
|
||||
<span class="text-xs text-subtext0 uppercase font-medium">Points</span>
|
||||
<p class="text-2xl font-bold text-blue">{ fmt.Sprint(info.Record.Points) }</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Record Row -->
|
||||
<div class="grid grid-cols-4 gap-2 text-center">
|
||||
<div class="bg-surface0 rounded px-2 py-1.5">
|
||||
<p class="text-xs text-subtext0 font-medium">W</p>
|
||||
<p class="text-sm font-bold text-green">{ fmt.Sprint(info.Record.Wins) }</p>
|
||||
</div>
|
||||
<div class="bg-surface0 rounded px-2 py-1.5">
|
||||
<p class="text-xs text-subtext0 font-medium">OTW</p>
|
||||
<p class="text-sm font-bold text-teal">{ fmt.Sprint(info.Record.OvertimeWins) }</p>
|
||||
</div>
|
||||
<div class="bg-surface0 rounded px-2 py-1.5">
|
||||
<p class="text-xs text-subtext0 font-medium">OTL</p>
|
||||
<p class="text-sm font-bold text-peach">{ fmt.Sprint(info.Record.OvertimeLosses) }</p>
|
||||
</div>
|
||||
<div class="bg-surface0 rounded px-2 py-1.5">
|
||||
<p class="text-xs text-subtext0 font-medium">L</p>
|
||||
<p class="text-sm font-bold text-red">{ fmt.Sprint(info.Record.Losses) }</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
func ordinal(n int) string {
|
||||
suffix := "th"
|
||||
if n%100 >= 11 && n%100 <= 13 {
|
||||
// 11th, 12th, 13th
|
||||
} else {
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
suffix = "st"
|
||||
case 2:
|
||||
suffix = "nd"
|
||||
case 3:
|
||||
suffix = "rd"
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%d%s", n, suffix)
|
||||
}
|
||||
@@ -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]) {
|
||||
<!-- Card grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
for _, t := range teams.Items {
|
||||
<div
|
||||
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0 transition-colors flex flex-col"
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/teams/%d", t.ID)) }
|
||||
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0
|
||||
transition-colors flex flex-col hover:cursor-pointer"
|
||||
>
|
||||
<!-- Header: Name with color indicator -->
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
@@ -102,7 +105,7 @@ templ TeamsList(teams *db.List[db.Team]) {
|
||||
{ t.AltShortName }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<!-- Pagination controls -->
|
||||
|
||||
Reference in New Issue
Block a user