Merge branch 'staging'

This commit is contained in:
2026-03-07 19:33:00 +11:00
51 changed files with 5708 additions and 569 deletions

View File

@@ -14,6 +14,7 @@ type Flags struct {
GenEnv string
EnvFile string
DevMode bool
Staging bool
// Database reset (destructive)
ResetDB bool
@@ -36,6 +37,7 @@ func SetupFlags() (*Flags, error) {
genEnv := flag.String("genenv", "", "Generate a .env file with all environment variables (specify filename)")
envfile := flag.String("envfile", ".env", "Specify a .env file to use for the configuration")
devMode := flag.Bool("dev", false, "Run the server in dev mode")
staging := flag.Bool("staging", false, "Show a staging banner")
// Database reset (destructive)
resetDB := flag.Bool("reset-db", false, "⚠️ DESTRUCTIVE: Drop and recreate all tables (dev only)")
@@ -92,6 +94,7 @@ func SetupFlags() (*Flags, error) {
GenEnv: *genEnv,
EnvFile: *envfile,
DevMode: *devMode,
Staging: *staging,
ResetDB: *resetDB,
MigrateUp: *migrateUp,
MigrateRollback: *migrateRollback,

View File

@@ -13,4 +13,5 @@ func DevMode(ctx context.Context) DevInfo {
type DevInfo struct {
WebsocketBase string
HTMXLog bool
StagingBanner bool
}

View File

@@ -18,7 +18,7 @@ func NewAudit(ipAdd, agent string, user *User) *AuditMeta {
func NewAuditFromRequest(r *http.Request) *AuditMeta {
u := CurrentUser(r.Context())
return &AuditMeta{r.RemoteAddr, r.UserAgent(), u}
return &AuditMeta{r.Header.Get("X-Forwarded-For"), r.UserAgent(), u}
}
// AuditInfo contains metadata for audit logging

View File

@@ -31,6 +31,12 @@ type FixtureResult struct {
TamperingDetected bool `bun:",default:false"`
TamperingReason *string
// Forfeit-related fields
IsForfeit bool `bun:"is_forfeit,default:false"`
ForfeitType *string `bun:"forfeit_type"` // "mutual" or "outright"
ForfeitTeam *string `bun:"forfeit_team"` // "home" or "away" (nil for mutual)
ForfeitReason *string `bun:"forfeit_reason"` // User-provided reason
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
UploadedBy *User `bun:"rel:belongs-to,join:uploaded_by_user_id=id"`
PlayerStats []*FixtureResultPlayerStats `bun:"rel:has-many,join:id=fixture_result_id"`
@@ -95,6 +101,106 @@ type PlayerWithPlayStatus struct {
Stats *FixtureResultPlayerStats // Period 3 (final cumulative) stats, nil if no result
}
// Forfeit type constants
const (
ForfeitTypeMutual = "mutual"
ForfeitTypeOutright = "outright"
)
// CreateForfeitResult creates a finalized forfeit result for a fixture.
// For outright forfeits, forfeitTeam specifies which team ("home" or "away") forfeited.
// For mutual forfeits, forfeitTeam should be empty.
func CreateForfeitResult(
ctx context.Context,
tx bun.Tx,
fixture *Fixture,
forfeitType string,
forfeitTeam string,
reason string,
userID int,
audit *AuditMeta,
) (*FixtureResult, error) {
if fixture == nil {
return nil, errors.New("fixture cannot be nil")
}
// Validate forfeit type
if forfeitType != ForfeitTypeMutual && forfeitType != ForfeitTypeOutright {
return nil, BadRequest("invalid forfeit type: must be 'mutual' or 'outright'")
}
// Validate forfeit team for outright forfeits
if forfeitType == ForfeitTypeOutright {
if forfeitTeam != "home" && forfeitTeam != "away" {
return nil, BadRequest("outright forfeit requires a team: must be 'home' or 'away'")
}
}
// Determine winner and scores based on forfeit type
var winner string
var homeScore, awayScore int
var endReason string
var forfeitTeamPtr *string
switch forfeitType {
case ForfeitTypeMutual:
// Mutual forfeit: both teams get an OT loss, no winner
// Use "draw" as winner to signal mutual loss
winner = "draw"
homeScore = 0
awayScore = 0
endReason = "Forfeit"
case ForfeitTypeOutright:
// Outright forfeit: forfeiting team loses, opponent wins
forfeitTeamPtr = &forfeitTeam
if forfeitTeam == "home" {
winner = "away"
} else {
winner = "home"
}
homeScore = 0
awayScore = 0
endReason = "Forfeit"
}
var reasonPtr *string
if reason != "" {
reasonPtr = &reason
}
result := &FixtureResult{
FixtureID: fixture.ID,
Winner: winner,
HomeScore: homeScore,
AwayScore: awayScore,
EndReason: endReason,
UploadedByUserID: userID,
Finalized: true, // Forfeits are immediately finalized
IsForfeit: true,
ForfeitType: &forfeitType,
ForfeitTeam: forfeitTeamPtr,
ForfeitReason: reasonPtr,
CreatedAt: time.Now().Unix(),
}
err := Insert(tx, result).WithAudit(audit, &AuditInfo{
Action: "fixture_results.forfeit",
ResourceType: "fixture_result",
ResourceID: nil,
Details: map[string]any{
"fixture_id": fixture.ID,
"forfeit_type": forfeitType,
"forfeit_team": forfeitTeam,
"reason": reason,
},
}).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert forfeit result")
}
return result, nil
}
// InsertFixtureResult stores a new match result with all player stats in a single transaction.
func InsertFixtureResult(
ctx context.Context,
@@ -275,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
@@ -305,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,
@@ -329,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
@@ -352,6 +769,7 @@ const (
// ComputeTeamRecord calculates W-OTW-OTL-L, GF/GA, and points from fixtures and results.
// Points: Win=3, OT Win=2, OT Loss=1, Loss=0.
// Forfeits: Outright = Win(3)/Loss(0), Mutual = OT Loss(1) for both teams.
func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*FixtureResult) *TeamRecord {
rec := &TeamRecord{}
for _, f := range fixtures {
@@ -361,6 +779,34 @@ func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*Fixtu
}
rec.Played++
isHome := f.HomeTeamID == teamID
// Handle forfeits separately
if res.IsForfeit {
// Forfeits have 0-0 score, no goal impact
if res.ForfeitType != nil && *res.ForfeitType == ForfeitTypeMutual {
// Mutual forfeit: both teams get OT loss (1 point)
rec.OvertimeLosses++
rec.Points += PointsOvertimeLoss
} else if res.ForfeitType != nil && *res.ForfeitType == ForfeitTypeOutright {
// Outright forfeit: check if this team forfeited
thisSide := "away"
if isHome {
thisSide = "home"
}
if res.ForfeitTeam != nil && *res.ForfeitTeam == thisSide {
// This team forfeited - loss
rec.Losses++
rec.Points += PointsLoss
} else {
// Opponent forfeited - win
rec.Wins++
rec.Points += PointsWin
}
}
continue
}
// Normal match handling
if isHome {
rec.GoalsFor += res.HomeScore
rec.GoalsAgainst += res.AwayScore

View File

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

View File

@@ -0,0 +1,89 @@
package migrations
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, conn *bun.DB) error {
// Add is_forfeit column
_, err := conn.NewAddColumn().
Model((*db.FixtureResult)(nil)).
ColumnExpr("is_forfeit BOOLEAN NOT NULL DEFAULT false").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Add forfeit_type column
_, err = conn.NewAddColumn().
Model((*db.FixtureResult)(nil)).
ColumnExpr("forfeit_type VARCHAR").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Add forfeit_team column
_, err = conn.NewAddColumn().
Model((*db.FixtureResult)(nil)).
ColumnExpr("forfeit_team VARCHAR").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Add forfeit_reason column
_, err = conn.NewAddColumn().
Model((*db.FixtureResult)(nil)).
ColumnExpr("forfeit_reason VARCHAR").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
_, err := conn.NewDropColumn().
Model((*db.FixtureResult)(nil)).
ColumnExpr("forfeit_reason").
Exec(ctx)
if err != nil {
return err
}
_, err = conn.NewDropColumn().
Model((*db.FixtureResult)(nil)).
ColumnExpr("forfeit_team").
Exec(ctx)
if err != nil {
return err
}
_, err = conn.NewDropColumn().
Model((*db.FixtureResult)(nil)).
ColumnExpr("forfeit_type").
Exec(ctx)
if err != nil {
return err
}
_, err = conn.NewDropColumn().
Model((*db.FixtureResult)(nil)).
ColumnExpr("is_forfeit").
Exec(ctx)
return err
},
)
}

View File

@@ -28,14 +28,15 @@ func (p *Player) DisplayName() string {
// NewPlayer creates a new player in the database. If there is an existing user with the same
// discordID, it will automatically link that user to the player
func NewPlayer(ctx context.Context, tx bun.Tx, discordID string, audit *AuditMeta) (*Player, error) {
player := &Player{DiscordID: discordID}
func NewPlayer(ctx context.Context, tx bun.Tx, name, discordID string, audit *AuditMeta) (*Player, error) {
player := &Player{DiscordID: discordID, Name: name}
user, err := GetUserByDiscordID(ctx, tx, discordID)
if err != nil && !IsBadRequest(err) {
return nil, errors.Wrap(err, "GetUserByDiscordID")
}
if user != nil {
player.UserID = &user.ID
player.Name = user.Username
}
err = Insert(tx, player).
WithAudit(audit, nil).Exec(ctx)
@@ -56,7 +57,7 @@ func (u *User) ConnectPlayer(ctx context.Context, tx bun.Tx, audit *AuditMeta) e
return errors.Wrap(err, "GetByField")
}
// Player doesn't exist, create a new one
player, err = NewPlayer(ctx, tx, u.DiscordID, audit)
player, err = NewPlayer(ctx, tx, u.Username, u.DiscordID, audit)
if err != nil {
return errors.Wrap(err, "NewPlayer")
}
@@ -98,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").

View File

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

View File

@@ -129,72 +129,29 @@
}
/* Custom Scrollbar Styles - Catppuccin Theme */
/* Only applied to specific elements (viewport, dropdowns, modals) to avoid
overriding the browser default scrollbar on the html/body level */
/* Firefox */
* {
/* Main content viewport */
#page-viewport {
--scrollbar-thumb: var(--overlay0);
--scrollbar-track: var(--base);
scrollbar-width: normal;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
/* Multi-select dropdowns */
.multi-select-dropdown {
--scrollbar-thumb: var(--surface2);
--scrollbar-track: var(--base);
scrollbar-width: thin;
scrollbar-color: var(--surface1) var(--mantle);
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
/* Webkit browsers (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--mantle);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--surface1);
border-radius: 4px;
border: 2px solid var(--mantle);
}
::-webkit-scrollbar-thumb:hover {
background: var(--surface2);
}
::-webkit-scrollbar-thumb:active {
background: var(--overlay0);
}
/* Specific styling for multi-select dropdowns */
.multi-select-dropdown::-webkit-scrollbar {
width: 6px;
}
.multi-select-dropdown::-webkit-scrollbar-track {
background: var(--base);
border-radius: 3px;
}
.multi-select-dropdown::-webkit-scrollbar-thumb {
background: var(--surface2);
border-radius: 3px;
border: 1px solid var(--base);
}
.multi-select-dropdown::-webkit-scrollbar-thumb:hover {
background: var(--overlay0);
}
/* Specific styling for modal content */
.modal-scrollable::-webkit-scrollbar {
width: 8px;
}
.modal-scrollable::-webkit-scrollbar-track {
background: var(--base);
}
.modal-scrollable::-webkit-scrollbar-thumb {
background: var(--surface1);
border-radius: 4px;
}
.modal-scrollable::-webkit-scrollbar-thumb:hover {
background: var(--surface2);
/* Modal content */
.modal-scrollable {
--scrollbar-thumb: var(--surface1);
--scrollbar-track: var(--base);
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}

View File

@@ -20,6 +20,7 @@
--container-3xl: 48rem;
--container-4xl: 56rem;
--container-5xl: 64rem;
--container-6xl: 72rem;
--container-7xl: 80rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
@@ -35,6 +36,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 +50,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);
@@ -263,6 +267,9 @@
.top-0 {
top: calc(var(--spacing) * 0);
}
.top-1 {
top: calc(var(--spacing) * 1);
}
.top-1\/2 {
top: calc(1 / 2 * 100%);
}
@@ -332,15 +339,15 @@
.-mt-3 {
margin-top: calc(var(--spacing) * -3);
}
.mt-0 {
margin-top: calc(var(--spacing) * 0);
}
.mt-0\.5 {
margin-top: calc(var(--spacing) * 0.5);
}
.mt-1 {
margin-top: calc(var(--spacing) * 1);
}
.mt-1\.5 {
margin-top: calc(var(--spacing) * 1.5);
}
.mt-2 {
margin-top: calc(var(--spacing) * 2);
}
@@ -368,12 +375,12 @@
.mt-12 {
margin-top: calc(var(--spacing) * 12);
}
.mt-16 {
margin-top: calc(var(--spacing) * 16);
}
.mt-24 {
margin-top: calc(var(--spacing) * 24);
}
.mt-25 {
margin-top: calc(var(--spacing) * 25);
}
.mt-auto {
margin-top: auto;
}
@@ -395,6 +402,9 @@
.mb-8 {
margin-bottom: calc(var(--spacing) * 8);
}
.mb-12 {
margin-bottom: calc(var(--spacing) * 12);
}
.mb-auto {
margin-bottom: auto;
}
@@ -450,6 +460,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 +472,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 +529,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 +541,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);
}
@@ -543,9 +577,6 @@
.w-80 {
width: calc(var(--spacing) * 80);
}
.w-fit {
width: fit-content;
}
.w-full {
width: 100%;
}
@@ -561,6 +592,9 @@
.max-w-5xl {
max-width: var(--container-5xl);
}
.max-w-6xl {
max-width: var(--container-6xl);
}
.max-w-7xl {
max-width: var(--container-7xl);
}
@@ -600,12 +634,22 @@
.flex-1 {
flex: 1;
}
.flex-shrink {
flex-shrink: 1;
}
.flex-shrink-0 {
flex-shrink: 0;
}
.shrink-0 {
flex-shrink: 0;
}
.border-collapse {
border-collapse: collapse;
}
.-translate-y-1 {
--tw-translate-y: calc(var(--spacing) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -636,6 +680,9 @@
.animate-spin {
animation: var(--animate-spin);
}
.cursor-default {
cursor: default;
}
.cursor-grab {
cursor: grab;
}
@@ -672,6 +719,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));
}
@@ -684,6 +734,9 @@
.place-content-center {
place-content: center;
}
.items-baseline {
align-items: baseline;
}
.items-center {
align-items: center;
}
@@ -693,6 +746,9 @@
.items-start {
align-items: flex-start;
}
.items-stretch {
align-items: stretch;
}
.justify-between {
justify-content: space-between;
}
@@ -702,6 +758,12 @@
.justify-end {
justify-content: flex-end;
}
.gap-0 {
gap: calc(var(--spacing) * 0);
}
.gap-0\.5 {
gap: calc(var(--spacing) * 0.5);
}
.gap-1 {
gap: calc(var(--spacing) * 1);
}
@@ -723,6 +785,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 +806,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 +841,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 +937,9 @@
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-md {
border-radius: var(--radius-md);
}
.rounded-xl {
border-radius: var(--radius-xl);
}
@@ -906,6 +992,15 @@
.border-overlay0 {
border-color: var(--overlay0);
}
.border-peach {
border-color: var(--peach);
}
.border-peach\/50 {
border-color: var(--peach);
@supports (color: color-mix(in lab, red, red)) {
border-color: color-mix(in oklab, var(--peach) 50%, transparent);
}
}
.border-red {
border-color: var(--red);
}
@@ -915,6 +1010,12 @@
border-color: color-mix(in oklab, var(--red) 30%, transparent);
}
}
.border-red\/50 {
border-color: var(--red);
@supports (color: color-mix(in lab, red, red)) {
border-color: color-mix(in oklab, var(--red) 50%, transparent);
}
}
.border-surface0 {
border-color: var(--surface0);
}
@@ -987,6 +1088,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)) {
@@ -1005,9 +1112,36 @@
.bg-mauve {
background-color: var(--mauve);
}
.bg-overlay0 {
background-color: var(--overlay0);
}
.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);
}
.bg-peach\/5 {
background-color: var(--peach);
@supports (color: color-mix(in lab, red, red)) {
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)) {
@@ -1017,18 +1151,36 @@
.bg-red {
background-color: var(--red);
}
.bg-red\/5 {
background-color: var(--red);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--red) 5%, transparent);
}
}
.bg-red\/10 {
background-color: var(--red);
@supports (color: color-mix(in lab, red, red)) {
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);
}
@@ -1086,6 +1238,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);
}
@@ -1107,6 +1262,9 @@
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
.py-0 {
padding-block: calc(var(--spacing) * 0);
}
.py-0\.5 {
padding-block: calc(var(--spacing) * 0.5);
}
@@ -1119,12 +1277,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);
}
@@ -1149,6 +1313,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);
}
@@ -1164,6 +1331,9 @@
.pl-3 {
padding-left: calc(var(--spacing) * 3);
}
.pl-4 {
padding-left: calc(var(--spacing) * 4);
}
.text-center {
text-align: center;
}
@@ -1188,6 +1358,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));
@@ -1263,6 +1437,9 @@
.whitespace-pre-wrap {
white-space: pre-wrap;
}
.text-base {
color: var(--base);
}
.text-blue {
color: var(--blue);
}
@@ -1275,6 +1452,9 @@
.text-mantle {
color: var(--mantle);
}
.text-mauve {
color: var(--mauve);
}
.text-overlay0 {
color: var(--overlay0);
}
@@ -1296,6 +1476,9 @@
color: color-mix(in oklab, var(--red) 80%, transparent);
}
}
.text-sky {
color: var(--sky);
}
.text-subtext0 {
color: var(--subtext0);
}
@@ -1341,6 +1524,9 @@
.italic {
font-style: italic;
}
.underline {
text-decoration-line: underline;
}
.placeholder-subtext0 {
&::placeholder {
color: var(--subtext0);
@@ -1371,6 +1557,10 @@
--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.blur {
--tw-blur: blur(8px);
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
@@ -1433,6 +1623,13 @@
}
}
}
.group-hover\:text-blue {
&:is(:where(.group):hover *) {
@media (hover: hover) {
color: var(--blue);
}
}
}
.group-hover\:opacity-100 {
&:is(:where(.group):hover *) {
@media (hover: hover) {
@@ -1495,6 +1692,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) {
@@ -1848,6 +2051,16 @@
--tw-ring-color: var(--mauve);
}
}
.focus\:ring-peach {
&:focus {
--tw-ring-color: var(--peach);
}
}
.focus\:ring-red {
&:focus {
--tw-ring-color: var(--red);
}
}
.focus\:outline-none {
&:focus {
--tw-outline-style: none;
@@ -1967,6 +2180,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;
@@ -2039,6 +2257,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);
@@ -2069,12 +2297,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;
@@ -2125,9 +2371,9 @@
display: flex;
}
}
.lg\:inline {
.lg\:w-auto {
@media (width >= 64rem) {
display: inline;
width: auto;
}
}
.lg\:grid-cols-2 {
@@ -2140,9 +2386,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 {
@@ -2269,56 +2515,23 @@
font-weight: 700;
font-style: italic;
}
* {
#page-viewport {
--scrollbar-thumb: var(--overlay0);
--scrollbar-track: var(--base);
scrollbar-width: normal;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
.multi-select-dropdown {
--scrollbar-thumb: var(--surface2);
--scrollbar-track: var(--base);
scrollbar-width: thin;
scrollbar-color: var(--surface1) var(--mantle);
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--mantle);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--surface1);
border-radius: 4px;
border: 2px solid var(--mantle);
}
::-webkit-scrollbar-thumb:hover {
background: var(--surface2);
}
::-webkit-scrollbar-thumb:active {
background: var(--overlay0);
}
.multi-select-dropdown::-webkit-scrollbar {
width: 6px;
}
.multi-select-dropdown::-webkit-scrollbar-track {
background: var(--base);
border-radius: 3px;
}
.multi-select-dropdown::-webkit-scrollbar-thumb {
background: var(--surface2);
border-radius: 3px;
border: 1px solid var(--base);
}
.multi-select-dropdown::-webkit-scrollbar-thumb:hover {
background: var(--overlay0);
}
.modal-scrollable::-webkit-scrollbar {
width: 8px;
}
.modal-scrollable::-webkit-scrollbar-track {
background: var(--base);
}
.modal-scrollable::-webkit-scrollbar-thumb {
background: var(--surface1);
border-radius: 4px;
}
.modal-scrollable::-webkit-scrollbar-thumb:hover {
background: var(--surface2);
.modal-scrollable {
--scrollbar-thumb: var(--surface1);
--scrollbar-track: var(--base);
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
@property --tw-translate-x {
syntax: "*";
@@ -2472,6 +2685,11 @@
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-blur {
syntax: "*";
inherits: false;
@@ -2574,6 +2792,7 @@
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-outline-style: solid;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;

View File

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

View File

@@ -2,6 +2,7 @@ package handlers
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
@@ -20,8 +21,7 @@ import (
"github.com/uptrace/bun"
)
// FixtureDetailPage renders the fixture detail page with scheduling UI, history,
// result display, and team rosters
// FixtureDetailPage redirects to the default tab (overview)
func FixtureDetailPage(
s *hws.Server,
conn *db.DB,
@@ -33,9 +33,230 @@ func FixtureDetailPage(
return
}
activeTab := r.URL.Query().Get("tab")
if activeTab == "" {
activeTab = "overview"
http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/overview", fixtureID), http.StatusSeeOther)
})
}
// FixtureDetailOverviewPage renders the overview tab of the fixture detail page
func FixtureDetailOverviewPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
var currentSchedule *db.FixtureSchedule
var canSchedule bool
var userTeamID int
var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus
var nominatedFreeAgents []*db.FixtureFreeAgent
var availableFreeAgents []*db.SeasonLeagueFreeAgent
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
currentSchedule, err = db.GetCurrentFixtureSchedule(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetCurrentFixtureSchedule")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err = fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
nominatedFreeAgents, err = db.GetNominatedFreeAgents(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
}
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
if canSchedule || canManage {
availableFreeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
}
}
return true, nil
}); !ok {
return
}
if r.Method == "GET" {
renderSafely(seasonsview.FixtureDetailOverviewPage(
fixture, currentSchedule, canSchedule, userTeamID,
result, rosters, nominatedFreeAgents, availableFreeAgents,
), s, r, w)
} else {
renderSafely(seasonsview.FixtureDetailOverviewContent(
fixture, currentSchedule, canSchedule, userTeamID,
result, rosters, nominatedFreeAgents, availableFreeAgents,
), s, r, w)
}
})
}
// FixtureDetailPreviewPage renders the match preview tab of the fixture detail page
func FixtureDetailPreviewPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus
var previewData *db.MatchPreviewData
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
previewData, err = db.ComputeMatchPreview(ctx, tx, fixture)
if err != nil {
return false, errors.Wrap(err, "db.ComputeMatchPreview")
}
return true, nil
}); !ok {
return
}
// If finalized, redirect to analysis instead
if result != nil && result.Finalized {
if r.Method == "GET" {
http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/analysis", fixtureID), http.StatusSeeOther)
} else {
respond.HXRedirect(w, "/fixtures/%d/analysis", fixtureID)
}
return
}
if r.Method == "GET" {
renderSafely(seasonsview.FixtureDetailPreviewPage(
fixture, result, rosters, previewData,
), s, r, w)
} else {
renderSafely(seasonsview.FixtureDetailPreviewContent(
fixture, rosters, previewData,
), s, r, w)
}
})
}
// FixtureDetailAnalysisPage renders the match analysis tab of the fixture detail page
func FixtureDetailAnalysisPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus
var previewData *db.MatchPreviewData
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
previewData, err = db.ComputeMatchPreview(ctx, tx, fixture)
if err != nil {
return false, errors.Wrap(err, "db.ComputeMatchPreview")
}
return true, nil
}); !ok {
return
}
// If not finalized, redirect to preview instead
if result == nil || !result.Finalized {
if r.Method == "GET" {
http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/preview", fixtureID), http.StatusSeeOther)
} else {
respond.HXRedirect(w, "/fixtures/%d/preview", fixtureID)
}
return
}
if r.Method == "GET" {
renderSafely(seasonsview.FixtureDetailAnalysisPage(
fixture, result, rosters, previewData,
), s, r, w)
} else {
renderSafely(seasonsview.FixtureDetailAnalysisContent(
fixture, result, rosters, previewData,
), s, r, w)
}
})
}
// FixtureDetailSchedulePage renders the schedule tab of the fixture detail page
func FixtureDetailSchedulePage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
@@ -44,9 +265,6 @@ func FixtureDetailPage(
var canSchedule bool
var userTeamID int
var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus
var nominatedFreeAgents []*db.FixtureFreeAgent
var availableFreeAgents []*db.SeasonLeagueFreeAgent
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
@@ -71,38 +289,34 @@ func FixtureDetailPage(
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
// Fetch fixture result if it exists
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
// Fetch team rosters with play status
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
// Fetch free agent nominations for this fixture
nominatedFreeAgents, err = db.GetNominatedFreeAgents(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
}
// Fetch available free agents for nomination (if user can schedule or manage fixtures)
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
if canSchedule || canManage {
availableFreeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
}
}
return true, nil
}); !ok {
return
}
renderSafely(seasonsview.FixtureDetailPage(
fixture, currentSchedule, history, canSchedule, userTeamID,
result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents,
), s, r, w)
// If finalized, redirect to overview (scheduling tab is hidden)
if result != nil && result.Finalized {
if r.Method == "GET" {
http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/overview", fixtureID), http.StatusSeeOther)
} else {
respond.HXRedirect(w, "/fixtures/%d/overview", fixtureID)
}
return
}
if r.Method == "GET" {
renderSafely(seasonsview.FixtureDetailSchedulePage(
fixture, currentSchedule, history, canSchedule, userTeamID,
), s, r, w)
} else {
renderSafely(seasonsview.FixtureDetailScheduleContent(
fixture, currentSchedule, history, canSchedule, userTeamID,
), s, r, w)
}
})
}

View File

@@ -0,0 +1,95 @@
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/respond"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/validation"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// ForfeitFixture handles POST /fixtures/{fixture_id}/forfeit
// Creates a finalized forfeit result for the fixture. Requires fixtures.manage permission.
func ForfeitFixture(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
forfeitType := getter.String("forfeit_type").TrimSpace().Required().Value
forfeitTeam := getter.String("forfeit_team").TrimSpace().Value
forfeitReason := getter.String("forfeit_reason").TrimSpace().Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
// Validate forfeit type
if forfeitType != db.ForfeitTypeMutual && forfeitType != db.ForfeitTypeOutright {
notify.Warn(s, w, r, "Invalid Forfeit Type", "Forfeit type must be 'mutual' or 'outright'.", nil)
return
}
// Validate forfeit team for outright forfeits
if forfeitType == db.ForfeitTypeOutright {
if forfeitTeam != "home" && forfeitTeam != "away" {
notify.Warn(s, w, r, "Missing Team", "An outright forfeit requires specifying which team forfeited.", nil)
return
}
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
// Check if a result already exists
existing, err := db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
if existing != nil {
notify.Warn(s, w, r, "Result Exists",
"A result already exists for this fixture. Discard it first to record a forfeit.", nil)
return false, nil
}
user := db.CurrentUser(ctx)
_, err = db.CreateForfeitResult(ctx, tx, fixture, forfeitType, forfeitTeam, forfeitReason, user.ID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Forfeit", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.CreateForfeitResult")
}
return true, nil
}); !ok {
return
}
notify.SuccessWithDelay(s, w, r, "Forfeit Recorded", "The forfeit has been recorded and finalized.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}

View File

@@ -1,22 +1,85 @@
package handlers
import (
"context"
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/throw"
homeview "git.haelnorr.com/h/oslstats/internal/view/homeview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// Index handles responses to the / path. Also serves a 404 Page for paths that
// don't have explicit handlers
func Index(s *hws.Server) http.Handler {
func Index(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
throw.NotFound(s, w, r, r.URL.Path)
return
}
renderSafely(homeview.IndexPage(), s, r, w)
var season *db.Season
var standings []homeview.LeagueStandings
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
// Get the most recent season
seasons, err := db.ListSeasons(ctx, tx, &db.PageOpts{
Page: 1,
PerPage: 1,
Order: bun.OrderDesc,
OrderBy: "start_date",
})
if err != nil {
return false, errors.Wrap(err, "db.ListSeasons")
}
if seasons.Total == 0 || len(seasons.Items) == 0 {
return true, nil
}
season = seasons.Items[0]
// Build leaderboards for each league in this season
standings = make([]homeview.LeagueStandings, 0, len(season.Leagues))
for _, league := range season.Leagues {
_, l, teams, err := db.GetSeasonLeagueWithTeams(ctx, tx, season.ShortName, league.ShortName)
if err != nil {
return false, errors.Wrap(err, "db.GetSeasonLeagueWithTeams")
}
fixtures, err := db.GetAllocatedFixtures(ctx, tx, season.ID, l.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetAllocatedFixtures")
}
fixtureIDs := make([]int, len(fixtures))
for i, f := range fixtures {
fixtureIDs[i] = f.ID
}
resultMap, err := db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
if err != nil {
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures")
}
leaderboard := db.ComputeLeaderboard(teams, fixtures, resultMap)
standings = append(standings, homeview.LeagueStandings{
League: l,
Leaderboard: leaderboard,
})
}
return true, nil
}); !ok {
return
}
renderSafely(homeview.IndexPage(season, standings), s, r, w)
},
)
}

View File

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

View File

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

View File

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

View File

@@ -54,14 +54,14 @@ func Register(
store.ClearRedirectTrack(r, "/register")
if r.Method == "GET" {
renderSafely(authview.RegisterPage(details.DiscordUser.Username), s, r, w)
renderSafely(authview.RegisterPage(""), s, r, w)
return
}
username := r.FormValue("username")
unique := false
var user *db.User
audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user)
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
if ok := conn.WithWriteTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
unique, err = db.IsUnique(ctx, tx, (*db.User)(nil), "username", username)
if err != nil {
return false, errors.Wrap(err, "db.IsUsernameUnique")
@@ -73,12 +73,13 @@ 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")
}
return true, nil
}); !ok {
throw.InternalServiceError(s, w, r, "Registration failed", err)
return
}
if !unique {
@@ -123,11 +124,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")

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
package handlers
import (
"context"
"net/http"
"strconv"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/throw"
teamsview "git.haelnorr.com/h/oslstats/internal/view/teamsview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// TeamDetailPage renders the global team detail page showing cross-season stats
func TeamDetailPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
teamIDStr := r.PathValue("team_id")
teamID, err := strconv.Atoi(teamIDStr)
if err != nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
var team *db.Team
var seasonInfos []*db.TeamSeasonInfo
var playerStats []*db.TeamAllTimePlayerStats
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
team, err = db.GetTeam(ctx, tx, teamID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetTeam")
}
seasonInfos, err = db.GetTeamSeasonParticipation(ctx, tx, teamID)
if err != nil {
return false, errors.Wrap(err, "db.GetTeamSeasonParticipation")
}
playerStats, err = db.GetTeamAllTimePlayerStats(ctx, tx, teamID)
if err != nil {
return false, errors.Wrap(err, "db.GetTeamAllTimePlayerStats")
}
return true, nil
}); !ok {
return
}
activeTab := r.URL.Query().Get("tab")
if activeTab != "seasons" && activeTab != "stats" {
activeTab = "seasons"
}
renderSafely(teamsview.DetailPage(team, seasonInfos, playerStats, activeTab), s, r, w)
})
}

View File

@@ -44,17 +44,21 @@ func addMiddleware(
func devMode(cfg *config.Config) hws.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if cfg.Flags.DevMode {
devInfo := contexts.DevInfo{
WebsocketBase: "ws://" + cfg.HWS.Host + ":" + strconv.FormatUint(cfg.HWS.Port, 10),
HTMXLog: true,
}
ctx := context.WithValue(r.Context(), contexts.DevModeKey, devInfo)
req := r.WithContext(ctx)
next.ServeHTTP(w, req)
if !cfg.Flags.DevMode && !cfg.Flags.Staging {
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
devInfo := contexts.DevInfo{}
if cfg.Flags.DevMode {
devInfo.WebsocketBase = "ws://" + cfg.HWS.Host + ":" + strconv.FormatUint(cfg.HWS.Port, 10)
devInfo.HTMXLog = true
}
if cfg.Flags.Staging {
devInfo.StagingBanner = true
}
ctx := context.WithValue(r.Context(), contexts.DevModeKey, devInfo)
req := r.WithContext(ctx)
next.ServeHTTP(w, req)
},
)
}

View File

@@ -39,7 +39,7 @@ func addRoutes(
{
Path: "/",
Method: hws.MethodGET,
Handler: handlers.Index(s),
Handler: handlers.Index(s, conn),
},
}
@@ -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{
@@ -214,6 +219,27 @@ func addRoutes(
Method: hws.MethodDELETE,
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)),
},
// Fixture detail tab routes
{
Path: "/fixtures/{fixture_id}/overview",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.FixtureDetailOverviewPage(s, conn),
},
{
Path: "/fixtures/{fixture_id}/preview",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.FixtureDetailPreviewPage(s, conn),
},
{
Path: "/fixtures/{fixture_id}/analysis",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.FixtureDetailAnalysisPage(s, conn),
},
{
Path: "/fixtures/{fixture_id}/scheduling",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.FixtureDetailSchedulePage(s, conn),
},
// Fixture scheduling routes
{
Path: "/fixtures/{fixture_id}/schedule",
@@ -287,6 +313,45 @@ func addRoutes(
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.DiscardMatchResult(s, conn)),
},
// Forfeit route
{
Path: "/fixtures/{fixture_id}/forfeit",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.ForfeitFixture(s, conn)),
},
}
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{
@@ -310,6 +375,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{
@@ -457,6 +527,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...)

View File

@@ -24,19 +24,19 @@ templ RegisterFormForm(username string) {
this.isChecking = false;
this.isUnique = false;
},
enableSubmit() {
this.canSubmit = true;
},
enableSubmit() {
this.canSubmit = true;
},
handleSubmit() {
this.isSubmitting = true;
this.buttontext = 'Loading...';
this.buttontext = "Loading...";
// Set timeout for 10 seconds
this.submitTimeout = setTimeout(() => {
this.isSubmitting = false;
this.buttontext = 'Register';
this.errorMessage = 'Request timed out. Please try again.';
this.buttontext = "Register";
this.errorMessage = "Request timed out. Please try again.";
}, 10000);
}
},
};
}
</script>
@@ -49,7 +49,7 @@ templ RegisterFormForm(username string) {
type="text"
id="username"
name="username"
x-bind:class="{
x-bind:class="{
'py-3 px-4 block w-full rounded-lg text-sm bg-base disabled:opacity-50 disabled:pointer-events-none border-2 outline-none': true,
'border-overlay0 focus:border-blue': !isUnique && !errorMessage,
'border-green focus:border-green': isUnique && !isChecking && !errorMessage,
@@ -60,19 +60,18 @@ templ RegisterFormForm(username string) {
value={ username }
@input="resetErr(); isEmpty = $el.value.trim() === ''; if(isEmpty) { errorMessage='Username is required'; isUnique=false; }"
hx-post="/htmx/isusernameunique"
hx-trigger="load delay:100ms, input changed delay:500ms"
hx-trigger="input changed delay:500ms"
hx-swap="none"
@htmx:before-request="if($el.value.trim() === '') { isEmpty=true; return; } isEmpty=false; isChecking=true; isUnique=false; errorMessage=''"
@htmx:after-request="isChecking=false; if($event.detail.successful) { isUnique=true; canSubmit=true; } else if($event.detail.xhr.status === 409) { errorMessage='Username is already taken'; isUnique=false; canSubmit=false; }"
/>
<p
class="text-center text-xs text-red mt-2"
id="username-error"
x-show="errorMessage && !isSubmitting"
x-cloak
x-text="errorMessage"
></p>
<p
class="text-center text-xs text-red mt-2"
id="username-error"
x-show="errorMessage && !isSubmitting"
x-cloak
x-text="errorMessage"
></p>
</div>
</div>
<button

View File

@@ -15,7 +15,7 @@ func getFooterItems() []FooterItem {
// Returns the template fragment for the Footer
templ Footer() {
<footer class="bg-mantle mt-10">
<div class="relative mx-auto max-w-screen-xl px-4 py-8 sm:px-6 lg:px-8">
<div class="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
@backToTopButton()
<div class="lg:flex lg:items-end lg:justify-between">
@footerBranding()
@@ -23,18 +23,26 @@ templ Footer() {
</div>
<div class="lg:flex lg:items-end lg:justify-between">
@footerCopyright()
@themeSelector()
</div>
</div>
</footer>
<script src="https://storage.ko-fi.com/cdn/scripts/overlay-widget.js"></script>
<script>
kofiWidgetOverlay.draw('haelnorr', {
'type': 'floating-chat',
'floating-chat.donateButton.text': 'Support me',
'floating-chat.donateButton.background-color': '#313244',
'floating-chat.donateButton.text-color': '#ccd5f3'
});
</script>
}
templ backToTopButton() {
<div class="absolute end-4 top-4 sm:end-6 lg:end-8">
<a
<button
class="inline-block rounded-full bg-teal p-2 text-crust
shadow-sm transition hover:bg-teal/75"
href="#main-content"
shadow-sm transition hover:bg-teal/75 hover:cursor-pointer"
onclick="document.getElementById('page-viewport').scrollTo({ top: 0, behavior: 'smooth' })"
>
<span class="sr-only">Back to top</span>
<svg
@@ -51,18 +59,15 @@ templ backToTopButton() {
clip-rule="evenodd"
></path>
</svg>
</a>
</button>
</div>
}
templ footerBranding() {
<div>
<div class="flex justify-center text-text lg:justify-start">
<div class="flex justify-center text-text lg:justify-start pb-4">
<span class="text-2xl">OSL Stats</span>
</div>
<p class="mx-auto max-w-md text-center leading-relaxed text-subtext0">
placeholder text
</p>
</div>
}
@@ -87,42 +92,7 @@ templ footerLinks(items []FooterItem) {
templ footerCopyright() {
<div>
<p class="mt-4 text-center text-sm text-overlay0">
by Haelnorr | placeholder text
by Haelnorr
</p>
</div>
}
templ themeSelector() {
<div>
<div class="mt-2 text-center">
<label for="theme-select" class="hidden lg:inline">Theme</label>
<select
name="ThemeSelect"
id="theme-select"
class="mt-1.5 inline rounded-lg bg-surface0 p-2 w-fit"
x-model="theme"
>
<template
x-for="themeopt in [
'dark',
'light',
'system',
]"
>
<option
x-text="displayThemeName(themeopt)"
:value="themeopt"
:selected="theme === themeopt"
></option>
</template>
</select>
<script>
const displayThemeName = (value) => {
if (value === "dark") return "Dark (Mocha)";
if (value === "light") return "Light (Latte)";
if (value === "system") return "System";
};
</script>
</div>
</div>
}

View File

@@ -11,13 +11,8 @@ templ Layout(title string) {
<!DOCTYPE html>
<html
lang="en"
x-data="{ theme: localStorage.getItem('theme') || 'system'}"
x-init="$watch('theme', (val) => localStorage.setItem('theme', val))"
x-bind:class="{'dark': theme === 'dark' || (theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches)}"
>
<head>
<script src="/static/js/theme.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ title }</title>
@@ -34,7 +29,7 @@ templ Layout(title string) {
}
</head>
<body
class="bg-base text-text ubuntu-mono-regular overflow-x-hidden"
class="bg-base dark text-text ubuntu-mono-regular overflow-hidden h-screen"
hx-ext="ws"
ws-connect={ devInfo.WebsocketBase + "/ws/notifications" }
>
@@ -43,21 +38,34 @@ templ Layout(title string) {
@popup.ConfirmModal()
<div
id="main-content"
class="flex flex-col h-screen justify-between"
class="flex flex-col h-screen"
>
if devInfo.StagingBanner {
@stagingBanner()
}
@Navbar()
if previewRole != nil {
@previewModeBanner(previewRole)
}
<div id="page-content" class="mb-auto md:px-5 md:pt-5">
{ children... }
<div id="page-viewport" class="flex-1 overflow-y-auto overflow-x-hidden">
<div id="page-content" class="min-h-full flex flex-col justify-between">
<div class="mb-auto md:px-5 md:pt-5">
{ children... }
</div>
@Footer()
</div>
</div>
@Footer()
</div>
</body>
</html>
}
templ stagingBanner() {
<div class="bg-peach text-crust text-center text-xs font-bold py-1 tracking-wider uppercase">
Staging Environment - For Testing Only
</div>
}
// Preview mode banner (private helper)
templ previewModeBanner(previewRole *db.Role) {
<div class="bg-yellow/20 border-b border-yellow/40 px-4 py-3">

View File

@@ -240,6 +240,7 @@ templ mobileNav(navItems []NavItem, user *db.User) {
<div
x-show="open"
x-transition
@click.outside="open = false"
class="absolute w-full bg-mantle sm:hidden z-10"
>
<div class="px-4 py-6">

View File

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

View File

@@ -0,0 +1,33 @@
package homeview
// ExternalLinks renders card tiles for external community resources
templ ExternalLinks() {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<a
href="http://slapshot.gg/osl"
target="_blank"
rel="noopener noreferrer"
class="bg-surface0 border border-surface1 rounded-lg p-6 hover:bg-surface1 transition hover:cursor-pointer group"
>
<h3 class="text-lg font-bold text-text group-hover:text-blue transition mb-2">
Join Our Discord
</h3>
<p class="text-sm text-subtext0">
Connect with other players, find teams, and stay up to date with league announcements.
</p>
</a>
<a
href="https://slapshot.gg/"
target="_blank"
rel="noopener noreferrer"
class="bg-surface0 border border-surface1 rounded-lg p-6 hover:bg-surface1 transition hover:cursor-pointer group"
>
<h3 class="text-lg font-bold text-text group-hover:text-blue transition mb-2">
Official Slapshot
</h3>
<p class="text-sm text-subtext0">
Visit the official Slapshot website to learn more about the game.
</p>
</a>
</div>
}

View File

@@ -1,13 +1,32 @@
package homeview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
// Page content for the index page
templ IndexPage() {
@baseview.Layout("OSL Stats") {
<div class="text-center mt-25">
<div class="text-4xl lg:text-6xl">OSL Stats</div>
<div>Placeholder text</div>
templ IndexPage(season *db.Season, standings []LeagueStandings) {
@baseview.Layout("Oceanic Slapshot League") {
<div class="max-w-screen-2xl mx-auto px-2">
<div class="mt-8 mb-12">
<h1 class="text-5xl lg:text-6xl font-bold text-text mb-6 text-center">
Oceanic Slapshot League
</h1>
<div class="max-w-3xl mx-auto bg-surface0 border border-surface1 rounded-lg p-6">
<p class="text-base text-subtext0 leading-relaxed">
The Oceanic Slapshot League (OSL) is a community for casual and competitive play of Slapshot: Rebound.
It is managed by a small group of community members, and aims to provide a place for players in the Oceanic
region (primarily Australia and New Zealand) to compete and play in organised League competitions, as well as
casual pick-up games (RPUGs) and public matches (in-game matchmaking).
The league is open to everyone, regardless of skill level.
</p>
</div>
</div>
<div class="max-w-6xl mx-auto mb-12">
@LatestStandings(season, standings)
</div>
<div class="max-w-6xl mx-auto mb-12">
@ExternalLinks()
</div>
</div>
}
}

View File

@@ -0,0 +1,151 @@
package homeview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt"
// LeagueStandings holds the data needed to render a single league's table
type LeagueStandings struct {
League *db.League
Leaderboard []*db.LeaderboardEntry
}
// LatestStandings renders the latest standings section with tabs to switch
// between leagues from the most recent season
templ LatestStandings(season *db.Season, standings []LeagueStandings) {
<div class="space-y-4">
<div class="flex items-baseline gap-3">
<h2 class="text-2xl font-bold text-text">Latest Standings</h2>
if season != nil {
<a
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s", season.ShortName)) }
class="text-sm text-subtext0 hover:text-blue transition"
>
{ season.Name }
</a>
}
</div>
if season == nil || len(standings) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No standings available yet.</p>
</div>
} else {
<div x-data={ fmt.Sprintf("{ activeTab: '%s' }", standings[0].League.ShortName) }>
if len(standings) > 1 {
<div class="flex gap-1 mb-4 border-b border-surface1">
for _, s := range standings {
<button
x-on:click={ fmt.Sprintf("activeTab = '%s'", s.League.ShortName) }
class="px-4 py-2 text-sm font-medium transition hover:cursor-pointer"
x-bind:class={ fmt.Sprintf("activeTab === '%s' ? 'text-blue border-b-2 border-blue' : 'text-subtext0 hover:text-text'", s.League.ShortName) }
>
{ s.League.Name }
</button>
}
</div>
}
for _, s := range standings {
<div x-show={ fmt.Sprintf("activeTab === '%s'", s.League.ShortName) }>
@standingsTable(season, s.League, s.Leaderboard)
</div>
}
</div>
}
</div>
}
templ standingsTable(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>
</div>
} else {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
<div class="bg-mantle border-b border-surface1 px-4 py-2 flex items-center gap-4 text-xs text-subtext0">
<span class="font-semibold text-subtext1">Points:</span>
<span>W = { fmt.Sprint(db.PointsWin) }</span>
<span>OTW = { fmt.Sprint(db.PointsOvertimeWin) }</span>
<span>OTL = { fmt.Sprint(db.PointsOvertimeLoss) }</span>
<span>L = { fmt.Sprint(db.PointsLoss) }</span>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-mantle border-b border-surface1">
<tr>
<th class="px-3 py-3 text-center text-xs font-semibold text-subtext0 w-10">#</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-text">Team</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Games Played">GP</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Wins">W</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Overtime Wins">OTW</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Overtime Losses">OTL</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Losses">L</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Goals For">GF</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Goals Against">GA</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Goal Differential">GD</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-blue" title="Points">PTS</th>
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for _, entry := range leaderboard {
@standingsRow(entry, season, league)
}
</tbody>
</table>
</div>
</div>
}
}
templ standingsRow(entry *db.LeaderboardEntry, season *db.Season, league *db.League) {
{{
r := entry.Record
goalDiff := r.GoalsFor - r.GoalsAgainst
var gdStr string
if goalDiff > 0 {
gdStr = fmt.Sprintf("+%d", goalDiff)
} else {
gdStr = fmt.Sprint(goalDiff)
}
}}
<tr class="hover:bg-surface1 transition-colors">
<td class="px-3 py-3 text-center text-sm font-medium text-subtext0">
{ fmt.Sprint(entry.Position) }
</td>
<td class="px-4 py-3">
@links.TeamLinkInSeason(entry.Team, season, league)
</td>
<td class="px-3 py-3 text-center text-sm text-subtext0">
{ fmt.Sprint(r.Played) }
</td>
<td class="px-3 py-3 text-center text-sm text-green">
{ fmt.Sprint(r.Wins) }
</td>
<td class="px-3 py-3 text-center text-sm text-teal">
{ fmt.Sprint(r.OvertimeWins) }
</td>
<td class="px-3 py-3 text-center text-sm text-peach">
{ fmt.Sprint(r.OvertimeLosses) }
</td>
<td class="px-3 py-3 text-center text-sm text-red">
{ fmt.Sprint(r.Losses) }
</td>
<td class="px-3 py-3 text-center text-sm text-text">
{ fmt.Sprint(r.GoalsFor) }
</td>
<td class="px-3 py-3 text-center text-sm text-text">
{ fmt.Sprint(r.GoalsAgainst) }
</td>
<td class="px-3 py-3 text-center text-sm">
if goalDiff > 0 {
<span class="text-green">{ gdStr }</span>
} else if goalDiff < 0 {
<span class="text-red">{ gdStr }</span>
} else {
<span class="text-subtext0">{ gdStr }</span>
}
</td>
<td class="px-3 py-3 text-center text-sm font-bold text-blue">
{ fmt.Sprint(r.Points) }
</td>
</tr>
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,34 +4,17 @@ 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"
templ FixtureDetailPage(
fixture *db.Fixture,
currentSchedule *db.FixtureSchedule,
history []*db.FixtureSchedule,
canSchedule bool,
userTeamID int,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
activeTab string,
nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent,
) {
// FixtureDetailLayout renders the fixture detail page layout with header and
// tab navigation. Tab content is rendered as children.
templ FixtureDetailLayout(activeTab string, fixture *db.Fixture, result *db.FixtureResult) {
{{
permCache := contexts.Permissions(ctx)
canManage := permCache.HasPermission(permissions.FixturesManage)
backURL := fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", fixture.Season.ShortName, fixture.League.ShortName)
isFinalized := result != nil && result.Finalized
if activeTab == "" {
activeTab = "overview"
}
// Force overview if schedule tab is hidden (result finalized)
if isFinalized && activeTab == "schedule" {
activeTab = "overview"
}
}}
@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,23 +53,25 @@ 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)
@fixtureTabItem("schedule", "Schedule", activeTab, fixture)
</ul>
</nav>
}
<!-- Tab Navigation -->
<nav class="bg-surface0 border-b border-surface1" data-tab-nav="fixture-detail-content">
<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("scheduling", "Schedule", activeTab, fixture)
}
</ul>
</nav>
</div>
<!-- Tab Content -->
if activeTab == "overview" {
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents)
} else if activeTab == "schedule" {
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
}
<!-- Content Area -->
<main id="fixture-detail-content">
{ children... }
</main>
</div>
<script src="/static/js/tabs.js" defer></script>
}
}
@@ -96,14 +81,15 @@ templ fixtureTabItem(section string, label string, activeTab string, fixture *db
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("/fixtures/%d", fixture.ID)
if section != "overview" {
url = fmt.Sprintf("/fixtures/%d?tab=%s", fixture.ID, section)
}
url := fmt.Sprintf("/fixtures/%d/%s", fixture.ID, section)
}}
<li class="inline-block">
<a
href={ templ.SafeURL(url) }
hx-post={ url }
hx-target="#fixture-detail-content"
hx-swap="innerHTML"
hx-push-url={ url }
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
>
{ label }
@@ -111,6 +97,107 @@ templ fixtureTabItem(section string, label string, activeTab string, fixture *db
</li>
}
// ==================== Full page wrappers (for GET requests / direct navigation) ====================
templ FixtureDetailOverviewPage(
fixture *db.Fixture,
currentSchedule *db.FixtureSchedule,
canSchedule bool,
userTeamID int,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent,
) {
@FixtureDetailLayout("overview", fixture, result) {
@FixtureDetailOverviewContent(fixture, currentSchedule, canSchedule, userTeamID, result, rosters, nominatedFreeAgents, availableFreeAgents)
}
}
templ FixtureDetailPreviewPage(
fixture *db.Fixture,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@FixtureDetailLayout("preview", fixture, result) {
@FixtureDetailPreviewContent(fixture, rosters, previewData)
}
}
templ FixtureDetailAnalysisPage(
fixture *db.Fixture,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@FixtureDetailLayout("analysis", fixture, result) {
@FixtureDetailAnalysisContent(fixture, result, rosters, previewData)
}
}
templ FixtureDetailSchedulePage(
fixture *db.Fixture,
currentSchedule *db.FixtureSchedule,
history []*db.FixtureSchedule,
canSchedule bool,
userTeamID int,
) {
@FixtureDetailLayout("scheduling", fixture, nil) {
@FixtureDetailScheduleContent(fixture, currentSchedule, history, canSchedule, userTeamID)
}
}
// ==================== Tab content components (for POST requests / HTMX swaps) ====================
templ FixtureDetailOverviewContent(
fixture *db.Fixture,
currentSchedule *db.FixtureSchedule,
canSchedule bool,
userTeamID int,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent,
) {
{{
permCache := contexts.Permissions(ctx)
canManage := permCache.HasPermission(permissions.FixturesManage)
}}
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents)
}
templ FixtureDetailPreviewContent(
fixture *db.Fixture,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@fixtureMatchPreviewTab(fixture, rosters, previewData)
}
templ FixtureDetailAnalysisContent(
fixture *db.Fixture,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@fixtureMatchAnalysisTab(fixture, result, rosters, previewData)
}
templ FixtureDetailScheduleContent(
fixture *db.Fixture,
currentSchedule *db.FixtureSchedule,
history []*db.FixtureSchedule,
canSchedule bool,
userTeamID int,
) {
{{
permCache := contexts.Permissions(ctx)
canManage := permCache.HasPermission(permissions.FixturesManage)
}}
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
}
// ==================== Overview Tab ====================
templ fixtureOverviewTab(
fixture *db.Fixture,
@@ -147,8 +234,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>
}
@@ -220,11 +307,28 @@ templ fixtureResultDisplay(fixture *db.Fixture, result *db.FixtureResult) {
isOT := strings.EqualFold(result.EndReason, "Overtime")
homeWon := result.Winner == "home"
awayWon := result.Winner == "away"
isForfeit := result.IsForfeit
isMutualForfeit := isForfeit && result.ForfeitType != nil && *result.ForfeitType == "mutual"
isOutrightForfeit := isForfeit && result.ForfeitType != nil && *result.ForfeitType == "outright"
_ = isMutualForfeit
forfeitTeamName := ""
if isOutrightForfeit && result.ForfeitTeam != nil {
if *result.ForfeitTeam == "home" {
forfeitTeamName = fixture.HomeTeam.Name
} else {
forfeitTeamName = fixture.AwayTeam.Name
}
}
}}
<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">
<h2 class="text-lg font-bold text-text">Match Result</h2>
<div class="flex items-center gap-2">
if isForfeit {
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
Forfeited
</span>
}
if result.Finalized {
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
Finalized
@@ -263,55 +367,154 @@ templ fixtureResultDisplay(fixture *db.Fixture, result *db.FixtureResult) {
<p class="text-red/80 text-xs">{ *result.TamperingReason }</p>
</div>
}
<!-- Score Display -->
<div class="flex items-center justify-center gap-6 py-4">
<div class="flex items-center gap-3">
if homeWon {
<span class="text-2xl">&#127942;</span>
if isForfeit {
<!-- Forfeit Display -->
<div class="flex flex-col items-center py-4 space-y-4">
if isMutualForfeit {
<div class="flex items-center justify-center gap-6">
<div class="flex items-center gap-3">
if fixture.HomeTeam.Color != "" {
<span
class="px-2.5 py-1 rounded text-sm font-bold"
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
fixture.HomeTeam.Color, fixture.HomeTeam.Color, fixture.HomeTeam.Color) }
>
{ fixture.HomeTeam.ShortName }
</span>
} else {
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
{ fixture.HomeTeam.ShortName }
</span>
}
</div>
<div class="flex flex-col items-center">
<span class="px-3 py-1.5 bg-peach/20 text-peach rounded-lg text-sm font-bold">
MUTUAL FORFEIT
</span>
</div>
<div class="flex items-center gap-3">
if fixture.AwayTeam.Color != "" {
<span
class="px-2.5 py-1 rounded text-sm font-bold"
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
fixture.AwayTeam.Color, fixture.AwayTeam.Color, fixture.AwayTeam.Color) }
>
{ fixture.AwayTeam.ShortName }
</span>
} else {
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
{ fixture.AwayTeam.ShortName }
</span>
}
</div>
</div>
<p class="text-sm text-subtext0">Both teams receive an overtime loss</p>
} else if isOutrightForfeit {
<div class="flex items-center justify-center gap-6">
<div class="flex items-center gap-3">
if homeWon {
<span class="text-2xl">&#127942;</span>
}
if fixture.HomeTeam.Color != "" {
<span
class="px-2.5 py-1 rounded text-sm font-bold"
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
fixture.HomeTeam.Color, fixture.HomeTeam.Color, fixture.HomeTeam.Color) }
>
{ fixture.HomeTeam.ShortName }
</span>
} else {
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
{ fixture.HomeTeam.ShortName }
</span>
}
</div>
<div class="flex flex-col items-center">
<span class="px-3 py-1.5 bg-red/20 text-red rounded-lg text-sm font-bold">
FORFEIT
</span>
</div>
<div class="flex items-center gap-3">
if fixture.AwayTeam.Color != "" {
<span
class="px-2.5 py-1 rounded text-sm font-bold"
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
fixture.AwayTeam.Color, fixture.AwayTeam.Color, fixture.AwayTeam.Color) }
>
{ fixture.AwayTeam.ShortName }
</span>
} else {
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
{ fixture.AwayTeam.ShortName }
</span>
}
if awayWon {
<span class="text-2xl">&#127942;</span>
}
</div>
</div>
<p class="text-sm text-subtext0">
{ forfeitTeamName } forfeited the match
</p>
}
if fixture.HomeTeam.Color != "" {
<span
class="px-2.5 py-1 rounded text-sm font-bold"
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
fixture.HomeTeam.Color, fixture.HomeTeam.Color, fixture.HomeTeam.Color) }
>
{ fixture.HomeTeam.ShortName }
</span>
} else {
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
{ fixture.HomeTeam.ShortName }
</span>
}
<span class="text-4xl font-bold text-text">{ fmt.Sprint(result.HomeScore) }</span>
</div>
<div class="flex flex-col items-center">
<span class="text-4xl text-subtext0 font-light leading-none"></span>
if isOT {
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-semibold mt-1">
OT
</span>
if result.ForfeitReason != nil && *result.ForfeitReason != "" {
<div class="bg-surface0 border border-surface1 rounded-lg p-3 max-w-md w-full">
<p class="text-xs text-subtext1 font-medium mb-1">Reason</p>
<p class="text-sm text-subtext0">{ *result.ForfeitReason }</p>
</div>
}
</div>
<div class="flex items-center gap-3">
<span class="text-4xl font-bold text-text">{ fmt.Sprint(result.AwayScore) }</span>
if fixture.AwayTeam.Color != "" {
<span
class="px-2.5 py-1 rounded text-sm font-bold"
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
fixture.AwayTeam.Color, fixture.AwayTeam.Color, fixture.AwayTeam.Color) }
>
{ fixture.AwayTeam.ShortName }
</span>
} else {
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
{ fixture.AwayTeam.ShortName }
</span>
}
if awayWon {
<span class="text-2xl">&#127942;</span>
}
} else {
<!-- Normal Score Display -->
<div class="flex items-center justify-center gap-6 py-4">
<div class="flex items-center gap-3">
if homeWon {
<span class="text-2xl">&#127942;</span>
}
if fixture.HomeTeam.Color != "" {
<span
class="px-2.5 py-1 rounded text-sm font-bold"
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
fixture.HomeTeam.Color, fixture.HomeTeam.Color, fixture.HomeTeam.Color) }
>
{ fixture.HomeTeam.ShortName }
</span>
} else {
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
{ fixture.HomeTeam.ShortName }
</span>
}
<span class="text-4xl font-bold text-text">{ fmt.Sprint(result.HomeScore) }</span>
</div>
<div class="flex flex-col items-center">
<span class="text-4xl text-subtext0 font-light leading-none"></span>
if isOT {
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-semibold mt-1">
OT
</span>
}
</div>
<div class="flex items-center gap-3">
<span class="text-4xl font-bold text-text">{ fmt.Sprint(result.AwayScore) }</span>
if fixture.AwayTeam.Color != "" {
<span
class="px-2.5 py-1 rounded text-sm font-bold"
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
fixture.AwayTeam.Color, fixture.AwayTeam.Color, fixture.AwayTeam.Color) }
>
{ fixture.AwayTeam.ShortName }
</span>
} else {
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
{ fixture.AwayTeam.ShortName }
</span>
}
if awayWon {
<span class="text-2xl">&#127942;</span>
}
</div>
</div>
</div>
}
</div>
</div>
}
@@ -321,17 +524,173 @@ templ fixtureUploadPrompt(fixture *db.Fixture) {
<div class="text-4xl mb-3">📋</div>
<p class="text-lg text-text font-medium mb-2">No Result Uploaded</p>
<p class="text-sm text-subtext1 mb-4">Upload match log files to record the result of this fixture.</p>
<a
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/results/upload", fixture.ID)) }
class="inline-block px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Upload Match Logs
</a>
<div class="flex items-center justify-center gap-3">
<a
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/results/upload", fixture.ID)) }
class="inline-block px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Upload Match Logs
</a>
<button
type="button"
@click="$dispatch('open-forfeit-modal')"
class="inline-block px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Forfeit Match
</button>
</div>
</div>
@forfeitModal(fixture)
}
templ forfeitModal(fixture *db.Fixture) {
<div
x-data="{
open: false,
forfeitType: 'outright',
forfeitTeam: '',
forfeitReason: '',
}"
@open-forfeit-modal.window="open = true"
x-show="open"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true"
>
<!-- Background overlay -->
<div
x-show="open"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-base/75 transition-opacity"
@click="open = false"
></div>
<!-- Modal panel -->
<div class="flex min-h-full items-center justify-center p-4">
<div
x-show="open"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="relative transform overflow-hidden rounded-lg bg-mantle border-2 border-surface1 shadow-xl transition-all sm:w-full sm:max-w-lg"
@click.stop
>
<form
hx-post={ fmt.Sprintf("/fixtures/%d/forfeit", fixture.ID) }
hx-swap="none"
>
<div class="bg-mantle px-4 pb-4 pt-5 sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red/10 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-red" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"></path>
</svg>
</div>
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left w-full">
<h3 class="text-lg font-semibold leading-6 text-text">Forfeit Match</h3>
<div class="mt-2">
<p class="text-sm text-subtext0 mb-4">
This will record a forfeit result. This action is immediate and cannot be undone.
</p>
<!-- Forfeit Type Selection -->
<div class="space-y-3">
<label class="text-sm font-medium text-text">Forfeit Type</label>
<div class="space-y-2">
<label class="flex items-center gap-3 p-3 bg-surface0 border border-surface1 rounded-lg hover:bg-surface1 transition hover:cursor-pointer"
:class="forfeitType === 'outright' && 'border-red/50 bg-red/5'"
>
<input
type="radio"
name="forfeit_type"
value="outright"
x-model="forfeitType"
class="text-red focus:ring-red hover:cursor-pointer"
/>
<div>
<span class="text-sm font-medium text-text">Outright Forfeit</span>
<p class="text-xs text-subtext0">One team forfeits. They receive a loss, the opponent receives a win.</p>
</div>
</label>
<label class="flex items-center gap-3 p-3 bg-surface0 border border-surface1 rounded-lg hover:bg-surface1 transition hover:cursor-pointer"
:class="forfeitType === 'mutual' && 'border-peach/50 bg-peach/5'"
>
<input
type="radio"
name="forfeit_type"
value="mutual"
x-model="forfeitType"
class="text-peach focus:ring-peach hover:cursor-pointer"
/>
<div>
<span class="text-sm font-medium text-text">Mutual Forfeit</span>
<p class="text-xs text-subtext0">Both teams forfeit. Each receives an overtime loss.</p>
</div>
</label>
</div>
</div>
<!-- Team Selection (outright only) -->
<div x-show="forfeitType === 'outright'" x-cloak class="mt-4 space-y-2">
<label class="text-sm font-medium text-text">Which team is forfeiting?</label>
<select
name="forfeit_team"
x-model="forfeitTeam"
class="w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text focus:border-red focus:outline-none hover:cursor-pointer"
>
<option value="">Select a team...</option>
<option value="home">{ fixture.HomeTeam.Name } (Home)</option>
<option value="away">{ fixture.AwayTeam.Name } (Away)</option>
</select>
</div>
<!-- Reason -->
<div class="mt-4 space-y-2">
<label class="text-sm font-medium text-text">Reason (optional)</label>
<textarea
name="forfeit_reason"
x-model="forfeitReason"
placeholder="Provide a reason for the forfeit..."
class="w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text
focus:border-blue focus:outline-none resize-none"
rows="3"
></textarea>
</div>
</div>
</div>
</div>
</div>
<div class="bg-surface0 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
<button
type="submit"
class="inline-flex w-full justify-center rounded-lg bg-red px-4 py-2 text-sm font-semibold text-mantle shadow-sm hover:bg-red/75 hover:cursor-pointer transition sm:w-auto"
:disabled="forfeitType === 'outright' && forfeitTeam === ''"
:class="forfeitType === 'outright' && forfeitTeam === '' && 'opacity-50 cursor-not-allowed'"
>
Confirm Forfeit
</button>
<button
type="button"
@click="open = false"
class="mt-3 inline-flex w-full justify-center rounded-lg bg-surface1 px-4 py-2 text-sm font-semibold text-text shadow-sm hover:bg-surface2 hover:cursor-pointer transition sm:mt-0 sm:w-auto"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
}
templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side string, result *db.FixtureResult) {
templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side string, result *db.FixtureResult, season *db.Season, league *db.League) {
{{
// Separate playing and bench players
var playing []*db.PlayerWithPlayStatus
@@ -368,8 +727,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
@@ -390,6 +749,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>
@@ -402,9 +762,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">
&#9733;
@@ -418,6 +778,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>
@@ -426,7 +787,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>
}
@@ -441,7 +802,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">
@@ -463,8 +824,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">
@@ -486,7 +847,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">
@@ -566,7 +927,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>
@@ -601,7 +964,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>

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/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)
}

View File

@@ -159,21 +159,35 @@ templ fixtureListItem(fixture *db.Fixture, sched *db.FixtureSchedule, hasSchedul
</span>
</div>
if hasResult {
<span class="flex items-center gap-2">
if res.Winner == "home" {
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
} else if res.Winner == "away" {
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
} else {
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
}
</span>
if res.IsForfeit {
<span class="flex items-center gap-2">
if res.ForfeitType != nil && *res.ForfeitType == "mutual" {
<span class="px-2 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
Mutual Forfeit
</span>
} else {
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
Forfeit
</span>
}
</span>
} else {
<span class="flex items-center gap-2">
if res.Winner == "home" {
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
} else if res.Winner == "away" {
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
} else {
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
}
</span>
}
} else if hasSchedule && sched.ScheduledTime != nil {
<span class="text-xs text-green font-medium">
@localtime(sched.ScheduledTime, "short")

View File

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

View File

@@ -1,15 +1,327 @@
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 w-full">
<!-- Top row: Goals and Assists side by side when room allows -->
<div class="flex flex-col lg:flex-row gap-6 justify-center items-stretch w-full lg:w-auto">
@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 min-w-0 w-full lg:w-auto">
<div class="bg-mantle border-b border-surface1 px-4 py-3">
<h3 class="text-sm font-semibold text-text">
Top Goal Scorers
</h3>
</div>
<!-- Sorting key -->
<div class="bg-mantle border-b border-surface1 px-4 py-1.5 flex items-center gap-3 text-xs text-subtext0">
<span class="font-semibold text-subtext1">Sort:</span>
<span>G &#8595;</span>
<span>PP &#8593;</span>
<span>SH &#8593;</span>
</div>
if len(goals) == 0 {
<div class="p-6 text-center">
<p class="text-subtext0 text-sm">No goal data available yet.</p>
</div>
} else {
<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-3 py-2 text-left text-xs font-semibold text-text">Team</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-blue" title="Goals">G</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for i, gs := range goals {
<tr class="hover:bg-surface1 transition-colors">
<td class="px-3 py-2 text-center text-sm font-medium text-subtext0">
{ fmt.Sprint(i + 1) }
</td>
<td class="px-3 py-2 text-sm font-medium whitespace-nowrap">
@links.PlayerLinkFromStats(gs.PlayerID, gs.PlayerName)
</td>
<td class="px-3 py-2 text-sm whitespace-nowrap">
@teamColorName(gs.TeamID, gs.TeamName, gs.TeamColor, season, league)
</td>
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(gs.Goals) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(gs.PeriodsPlayed) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(gs.Shots) }</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
templ topAssistersTable(season *db.Season, league *db.League, assists []*db.LeagueTopAssister) {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden min-w-0 w-full lg:w-auto">
<div class="bg-mantle border-b border-surface1 px-4 py-3">
<h3 class="text-sm font-semibold text-text">
Top Assisters
</h3>
</div>
<!-- Sorting key -->
<div class="bg-mantle border-b border-surface1 px-4 py-1.5 flex items-center gap-3 text-xs text-subtext0">
<span class="font-semibold text-subtext1">Sort:</span>
<span>A &#8595;</span>
<span>PP &#8593;</span>
<span>PA &#8595;</span>
</div>
if len(assists) == 0 {
<div class="p-6 text-center">
<p class="text-subtext0 text-sm">No assist data available yet.</p>
</div>
} else {
<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-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>
}
</div>
}
templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTopSaver) {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden min-w-0 w-full lg:w-auto">
<div class="bg-mantle border-b border-surface1 px-4 py-3">
<h3 class="text-sm font-semibold text-text">
Top Saves
</h3>
</div>
<!-- Sorting key -->
<div class="bg-mantle border-b border-surface1 px-4 py-1.5 flex items-center gap-3 text-xs text-subtext0">
<span class="font-semibold text-subtext1">Sort:</span>
<span>SV &#8595;</span>
<span>PP &#8593;</span>
<span>BLK &#8595;</span>
</div>
if len(saves) == 0 {
<div class="p-6 text-center">
<p class="text-subtext0 text-sm">No save data available yet.</p>
</div>
} else {
<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-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>
}
</div>
}
templ allStatsTable(season *db.Season, league *db.League, allStats []*db.LeaguePlayerStats) {
<div
class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"
x-data="sortableTable('score', 'desc')"
>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-mantle border-b border-surface1">
<tr>
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Team</th>
@sortableCol("gp", "GP", "Games Played")
@sortableCol("pp", "PP", "Periods Played")
@sortableCol("score", "SC", "Score")
@sortableCol("goals", "G", "Goals")
@sortableCol("assists", "A", "Assists")
@sortableCol("pa", "PA", "Primary Assists")
@sortableCol("sa", "SA", "Secondary Assists")
@sortableCol("saves", "SV", "Saves")
@sortableCol("shots", "SH", "Shots")
@sortableCol("blocks", "BLK", "Blocks")
@sortableCol("passes", "PAS", "Passes")
</tr>
</thead>
<tbody class="divide-y divide-surface1" x-ref="tbody">
for _, ps := range allStats {
<tr
class="hover:bg-surface1 transition-colors"
data-name={ ps.PlayerName }
data-team={ ps.TeamName }
data-gp={ fmt.Sprint(ps.GamesPlayed) }
data-pp={ fmt.Sprint(ps.PeriodsPlayed) }
data-score={ fmt.Sprint(ps.Score) }
data-goals={ fmt.Sprint(ps.Goals) }
data-assists={ fmt.Sprint(ps.Assists) }
data-pa={ fmt.Sprint(ps.PrimaryAssists) }
data-sa={ fmt.Sprint(ps.SecondaryAssists) }
data-saves={ fmt.Sprint(ps.Saves) }
data-shots={ fmt.Sprint(ps.Shots) }
data-blocks={ fmt.Sprint(ps.Blocks) }
data-passes={ fmt.Sprint(ps.Passes) }
>
<td class="px-3 py-2 text-sm font-medium">
@links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName)
</td>
<td class="px-3 py-2 text-sm">
@teamColorName(ps.TeamID, ps.TeamName, ps.TeamColor, season, league)
</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.GamesPlayed) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.PeriodsPlayed) }</td>
<td class="px-2 py-2 text-center text-sm text-text font-medium">{ fmt.Sprint(ps.Score) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Goals) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Assists) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.PrimaryAssists) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.SecondaryAssists) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Saves) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.Shots) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.Blocks) }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.Passes) }</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
templ sortableCol(field string, label string, title string) {
<th
class="px-2 py-2 text-center text-xs font-semibold text-text select-none hover:cursor-pointer hover:text-blue transition-colors"
title={ title }
@click={ fmt.Sprintf("sort('%s')", field) }
>
<span class="inline-flex items-center gap-0.5">
{ label }
<template x-if={ fmt.Sprintf("sortField === '%s'", field) }>
<span class="text-blue" x-text={ "sortDir === 'asc' ? '↑' : '↓'" }></span>
</template>
</span>
</th>
}
templ teamColorName(teamID int, teamName string, teamColor string, season *db.Season, league *db.League) {
if teamID > 0 && teamName != "" {
<a
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams/%d", season.ShortName, league.ShortName, teamID)) }
class="flex items-center gap-2 hover:text-blue transition"
>
if teamColor != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(teamColor) }
></span>
}
<span class="text-sm font-medium whitespace-nowrap">{ teamName }</span>
</a>
} else {
<span class="text-sm text-subtext0 italic">—</span>
}
}

View File

@@ -1,15 +1,16 @@
package seasonsview
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) }

View File

@@ -4,11 +4,12 @@ import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/permissions"
import "git.haelnorr.com/h/oslstats/internal/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">
&#9733; 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>
}
@@ -520,13 +543,17 @@ templ teamResultRow(team *db.Team, fixture *db.Fixture, resultMap map[int]*db.Fi
won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away")
lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home")
_ = lost
isForfeit := res.IsForfeit
isMutualForfeit := isForfeit && res.ForfeitType != nil && *res.ForfeitType == "mutual"
}}
<a
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
class="px-4 py-3 flex items-center justify-between gap-3 hover:bg-surface1 transition hover:cursor-pointer block"
>
<div class="flex items-center gap-3 min-w-0">
if won {
if isMutualForfeit {
<span class="text-xs font-bold px-2 py-0.5 bg-peach/20 text-peach rounded shrink-0">FF</span>
} else if won {
<span class="text-xs font-bold px-2 py-0.5 bg-green/20 text-green rounded shrink-0">W</span>
} else if lost {
<span class="text-xs font-bold px-2 py-0.5 bg-red/20 text-red rounded shrink-0">L</span>
@@ -550,83 +577,130 @@ templ teamResultRow(team *db.Team, fixture *db.Fixture, resultMap map[int]*db.Fi
{ opponent }
</span>
</div>
<span class="flex items-center gap-2 shrink-0">
if res.Winner == "home" {
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
} else if res.Winner == "away" {
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
} else {
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
}
</span>
if isForfeit {
<span class="flex items-center gap-2 shrink-0">
if isMutualForfeit {
<span class="px-2 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
Mutual Forfeit
</span>
} else {
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
Forfeit
</span>
}
</span>
} else {
<span class="flex items-center gap-2 shrink-0">
if res.Winner == "home" {
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
} else if res.Winner == "away" {
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
} else {
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
}
</span>
}
</a>
}
templ teamStatsSection(record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) {
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>
}

View File

@@ -0,0 +1,81 @@
package teamsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "fmt"
templ DetailPage(team *db.Team, seasonInfos []*db.TeamSeasonInfo, playerStats []*db.TeamAllTimePlayerStats, activeTab string) {
@baseview.Layout(team.Name) {
<div class="max-w-screen-xl mx-auto px-4 py-8">
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<!-- Header -->
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div class="flex items-center gap-4">
if team.Color != "" {
<div
class="w-12 h-12 rounded-full border-2 border-surface1 shrink-0"
style={ "background-color: " + templ.SafeCSS(team.Color) }
></div>
}
<div>
<h1 class="text-4xl font-bold text-text">{ team.Name }</h1>
<div class="flex items-center gap-2 mt-2 flex-wrap">
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
{ team.ShortName }
</span>
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
{ team.AltShortName }
</span>
</div>
</div>
</div>
<a
href="/teams"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-surface1 hover:bg-surface2 text-text transition"
>
Back to Teams
</a>
</div>
</div>
<!-- Tab Navigation -->
<nav class="bg-surface0 border-b border-surface1">
<ul class="flex flex-wrap">
@teamDetailTab("seasons", "Seasons", activeTab, team)
@teamDetailTab("stats", "Player Stats", activeTab, team)
</ul>
</nav>
</div>
<!-- Tab Content -->
<div class="mt-6">
if activeTab == "seasons" {
@TeamDetailSeasons(team, seasonInfos)
} else if activeTab == "stats" {
@TeamDetailPlayerStats(playerStats)
}
</div>
</div>
}
}
templ teamDetailTab(section string, label string, activeTab string, team *db.Team) {
{{
isActive := section == activeTab
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
activeClasses := "border-blue text-blue font-semibold"
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
url := fmt.Sprintf("/teams/%d", team.ID)
if section != "seasons" {
url = fmt.Sprintf("/teams/%d?tab=%s", team.ID, section)
}
}}
<li class="inline-block">
<a
href={ templ.SafeURL(url) }
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
>
{ label }
</a>
</li>
}

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import "git.haelnorr.com/h/oslstats/internal/view/sort"
import "git.haelnorr.com/h/oslstats/internal/contexts"
import "git.haelnorr.com/h/oslstats/internal/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 -->

View File

@@ -14,7 +14,7 @@ default:
# Build the target binary
[group('build')]
build target=entrypoint: tailwind (_build target)
build target=entrypoint: templ tailwind (_build target)
_build target=entrypoint: tidy (generate target)
go build -ldflags="-w -s" -o {{bin}}/{{target}} {{cmd}}/{{target}}