Compare commits
39 Commits
b91725f53f
...
656fa6c255
| Author | SHA1 | Date | |
|---|---|---|---|
| 656fa6c255 | |||
| fd002a7ad0 | |||
| 2835ef74fc | |||
| 9e729d20b3 | |||
| ad93c44fae | |||
| af42c16faf | |||
| ba0844048a | |||
| 1cab39a4f7 | |||
| 26ee81964d | |||
| e0fd3b0a45 | |||
| 34cba6a81f | |||
| 14e10d095e | |||
| dd1ed61adb | |||
| 9ad29586f2 | |||
| 78db8d0324 | |||
| 04389970ac | |||
| 1194d46613 | |||
| 3b1eeaf12d | |||
| 4064c9c557 | |||
| 9f6a2303a0 | |||
| 8b414ff7f0 | |||
| 7b934295c6 | |||
| c73758c91c | |||
| f5c9e70edf | |||
| b957df8d32 | |||
| b96aeef32e | |||
| ce659f7d56 | |||
| a472314474 | |||
| 76987adceb | |||
| 060301f2c2 | |||
| 788346d269 | |||
| b57fbcd302 | |||
| 1634b27991 | |||
| cade057e42 | |||
| e526f42ac3 | |||
| 088478e6c1 | |||
| 08344877c7 | |||
| 36b42d6267 | |||
| 2b5c43cf61 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
.env
|
.env
|
||||||
|
*.env
|
||||||
*.db*
|
*.db*
|
||||||
.logs/
|
.logs/
|
||||||
server.log
|
server.log
|
||||||
@@ -10,6 +11,7 @@ internal/view/**/*_templ.go
|
|||||||
internal/view/**/*_templ.txt
|
internal/view/**/*_templ.txt
|
||||||
cmd/test/*
|
cmd/test/*
|
||||||
.opencode
|
.opencode
|
||||||
|
prod-export.sql
|
||||||
|
|
||||||
# Database backups (compressed)
|
# Database backups (compressed)
|
||||||
backups/*.sql.gz
|
backups/*.sql.gz
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Flags struct {
|
|||||||
GenEnv string
|
GenEnv string
|
||||||
EnvFile string
|
EnvFile string
|
||||||
DevMode bool
|
DevMode bool
|
||||||
|
Staging bool
|
||||||
|
|
||||||
// Database reset (destructive)
|
// Database reset (destructive)
|
||||||
ResetDB bool
|
ResetDB bool
|
||||||
@@ -36,6 +37,7 @@ func SetupFlags() (*Flags, error) {
|
|||||||
genEnv := flag.String("genenv", "", "Generate a .env file with all environment variables (specify filename)")
|
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")
|
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")
|
devMode := flag.Bool("dev", false, "Run the server in dev mode")
|
||||||
|
staging := flag.Bool("staging", false, "Show a staging banner")
|
||||||
|
|
||||||
// Database reset (destructive)
|
// Database reset (destructive)
|
||||||
resetDB := flag.Bool("reset-db", false, "⚠️ DESTRUCTIVE: Drop and recreate all tables (dev only)")
|
resetDB := flag.Bool("reset-db", false, "⚠️ DESTRUCTIVE: Drop and recreate all tables (dev only)")
|
||||||
@@ -92,6 +94,7 @@ func SetupFlags() (*Flags, error) {
|
|||||||
GenEnv: *genEnv,
|
GenEnv: *genEnv,
|
||||||
EnvFile: *envfile,
|
EnvFile: *envfile,
|
||||||
DevMode: *devMode,
|
DevMode: *devMode,
|
||||||
|
Staging: *staging,
|
||||||
ResetDB: *resetDB,
|
ResetDB: *resetDB,
|
||||||
MigrateUp: *migrateUp,
|
MigrateUp: *migrateUp,
|
||||||
MigrateRollback: *migrateRollback,
|
MigrateRollback: *migrateRollback,
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ func DevMode(ctx context.Context) DevInfo {
|
|||||||
type DevInfo struct {
|
type DevInfo struct {
|
||||||
WebsocketBase string
|
WebsocketBase string
|
||||||
HTMXLog bool
|
HTMXLog bool
|
||||||
|
StagingBanner bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ type FixtureResult struct {
|
|||||||
TamperingDetected bool `bun:",default:false"`
|
TamperingDetected bool `bun:",default:false"`
|
||||||
TamperingReason *string
|
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"`
|
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
|
||||||
UploadedBy *User `bun:"rel:belongs-to,join:uploaded_by_user_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"`
|
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
|
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.
|
// InsertFixtureResult stores a new match result with all player stats in a single transaction.
|
||||||
func InsertFixtureResult(
|
func InsertFixtureResult(
|
||||||
ctx context.Context,
|
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.
|
// AggregatedPlayerStats holds summed stats for a player across multiple fixtures.
|
||||||
type AggregatedPlayerStats struct {
|
type AggregatedPlayerStats struct {
|
||||||
PlayerID int `bun:"player_id"`
|
PlayerID int `bun:"player_id"`
|
||||||
PlayerName string `bun:"player_name"`
|
PlayerName string `bun:"player_name"`
|
||||||
GamesPlayed int `bun:"games_played"`
|
GamesPlayed int `bun:"games_played"`
|
||||||
Score int `bun:"total_score"`
|
PeriodsPlayed int `bun:"total_periods_played"`
|
||||||
Goals int `bun:"total_goals"`
|
Score int `bun:"total_score"`
|
||||||
Assists int `bun:"total_assists"`
|
Goals int `bun:"total_goals"`
|
||||||
Saves int `bun:"total_saves"`
|
Assists int `bun:"total_assists"`
|
||||||
Shots int `bun:"total_shots"`
|
Saves int `bun:"total_saves"`
|
||||||
Blocks int `bun:"total_blocks"`
|
Shots int `bun:"total_shots"`
|
||||||
Passes int `bun:"total_passes"`
|
Blocks int `bun:"total_blocks"`
|
||||||
|
Passes int `bun:"total_passes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAggregatedPlayerStatsForTeam returns aggregated period-3 stats for all mapped
|
// GetAggregatedPlayerStatsForTeam returns aggregated period-3 stats for all mapped
|
||||||
@@ -305,6 +412,7 @@ func GetAggregatedPlayerStatsForTeam(
|
|||||||
frps.player_id AS player_id,
|
frps.player_id AS player_id,
|
||||||
COALESCE(p.name, frps.player_username) AS player_name,
|
COALESCE(p.name, frps.player_username) AS player_name,
|
||||||
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
|
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
|
||||||
|
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
||||||
COALESCE(SUM(frps.score), 0) AS total_score,
|
COALESCE(SUM(frps.score), 0) AS total_score,
|
||||||
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
||||||
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
||||||
@@ -329,6 +437,319 @@ func GetAggregatedPlayerStatsForTeam(
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LeaguePlayerStats holds all aggregated stats for a player in a season-league.
|
||||||
|
type LeaguePlayerStats struct {
|
||||||
|
PlayerID int `bun:"player_id"`
|
||||||
|
PlayerName string `bun:"player_name"`
|
||||||
|
TeamID int `bun:"team_id"`
|
||||||
|
TeamName string `bun:"team_name"`
|
||||||
|
TeamColor string `bun:"team_color"`
|
||||||
|
GamesPlayed int `bun:"games_played"`
|
||||||
|
PeriodsPlayed int `bun:"total_periods_played"`
|
||||||
|
Goals int `bun:"total_goals"`
|
||||||
|
Assists int `bun:"total_assists"`
|
||||||
|
PrimaryAssists int `bun:"total_primary_assists"`
|
||||||
|
SecondaryAssists int `bun:"total_secondary_assists"`
|
||||||
|
Saves int `bun:"total_saves"`
|
||||||
|
Shots int `bun:"total_shots"`
|
||||||
|
Blocks int `bun:"total_blocks"`
|
||||||
|
Passes int `bun:"total_passes"`
|
||||||
|
Score int `bun:"total_score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllLeaguePlayerStats returns aggregated stats for all players in a season-league.
|
||||||
|
// Stats are combined across all teams a player may have played on,
|
||||||
|
// and the player's current roster team is shown.
|
||||||
|
func GetAllLeaguePlayerStats(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID int,
|
||||||
|
) ([]*LeaguePlayerStats, error) {
|
||||||
|
if seasonID == 0 {
|
||||||
|
return nil, errors.New("seasonID not provided")
|
||||||
|
}
|
||||||
|
if leagueID == 0 {
|
||||||
|
return nil, errors.New("leagueID not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats []*LeaguePlayerStats
|
||||||
|
err := tx.NewRaw(`
|
||||||
|
SELECT
|
||||||
|
agg.player_id,
|
||||||
|
agg.player_name,
|
||||||
|
COALESCE(tr.team_id, 0) AS team_id,
|
||||||
|
COALESCE(t.name, '') AS team_name,
|
||||||
|
COALESCE(t.color, '') AS team_color,
|
||||||
|
agg.games_played,
|
||||||
|
agg.total_periods_played,
|
||||||
|
agg.total_goals,
|
||||||
|
agg.total_assists,
|
||||||
|
agg.total_primary_assists,
|
||||||
|
agg.total_secondary_assists,
|
||||||
|
agg.total_saves,
|
||||||
|
agg.total_shots,
|
||||||
|
agg.total_blocks,
|
||||||
|
agg.total_passes,
|
||||||
|
agg.total_score
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
frps.player_id AS player_id,
|
||||||
|
COALESCE(p.name, frps.player_username) AS player_name,
|
||||||
|
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
|
||||||
|
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
||||||
|
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
||||||
|
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
||||||
|
COALESCE(SUM(frps.primary_assists), 0) AS total_primary_assists,
|
||||||
|
COALESCE(SUM(frps.secondary_assists), 0) AS total_secondary_assists,
|
||||||
|
COALESCE(SUM(frps.saves), 0) AS total_saves,
|
||||||
|
COALESCE(SUM(frps.shots), 0) AS total_shots,
|
||||||
|
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
|
||||||
|
COALESCE(SUM(frps.passes), 0) AS total_passes,
|
||||||
|
COALESCE(SUM(frps.score), 0) AS total_score
|
||||||
|
FROM fixture_result_player_stats frps
|
||||||
|
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
||||||
|
JOIN fixtures f ON f.id = fr.fixture_id
|
||||||
|
LEFT JOIN players p ON p.id = frps.player_id
|
||||||
|
WHERE fr.finalized = true
|
||||||
|
AND f.season_id = ?
|
||||||
|
AND f.league_id = ?
|
||||||
|
AND f.round > 0
|
||||||
|
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 f.round > 0
|
||||||
|
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 f.round > 0
|
||||||
|
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 f.round > 0
|
||||||
|
AND frps.period_num = 3
|
||||||
|
AND frps.player_id IS NOT NULL
|
||||||
|
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
||||||
|
ORDER BY total_saves DESC, total_periods_played ASC, total_blocks DESC
|
||||||
|
LIMIT 10
|
||||||
|
) agg
|
||||||
|
LEFT JOIN team_rosters tr
|
||||||
|
ON tr.player_id = agg.player_id
|
||||||
|
AND tr.season_id = ?
|
||||||
|
AND tr.league_id = ?
|
||||||
|
LEFT JOIN teams t ON t.id = tr.team_id
|
||||||
|
ORDER BY agg.total_saves DESC, agg.total_periods_played ASC, agg.total_blocks DESC
|
||||||
|
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewRaw")
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
// TeamRecord holds win/loss/draw record and goal totals for a team.
|
// TeamRecord holds win/loss/draw record and goal totals for a team.
|
||||||
type TeamRecord struct {
|
type TeamRecord struct {
|
||||||
Played int
|
Played int
|
||||||
@@ -352,6 +773,7 @@ const (
|
|||||||
|
|
||||||
// ComputeTeamRecord calculates W-OTW-OTL-L, GF/GA, and points from fixtures and results.
|
// 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.
|
// 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 {
|
func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*FixtureResult) *TeamRecord {
|
||||||
rec := &TeamRecord{}
|
rec := &TeamRecord{}
|
||||||
for _, f := range fixtures {
|
for _, f := range fixtures {
|
||||||
@@ -361,6 +783,34 @@ func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*Fixtu
|
|||||||
}
|
}
|
||||||
rec.Played++
|
rec.Played++
|
||||||
isHome := f.HomeTeamID == teamID
|
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 {
|
if isHome {
|
||||||
rec.GoalsFor += res.HomeScore
|
rec.GoalsFor += res.HomeScore
|
||||||
rec.GoalsAgainst += res.AwayScore
|
rec.GoalsAgainst += res.AwayScore
|
||||||
|
|||||||
254
internal/db/match_preview.go
Normal file
254
internal/db/match_preview.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Game outcome type constants.
|
||||||
|
const (
|
||||||
|
OutcomeWin = "W"
|
||||||
|
OutcomeLoss = "L"
|
||||||
|
OutcomeOTWin = "OTW"
|
||||||
|
OutcomeOTLoss = "OTL"
|
||||||
|
OutcomeDraw = "D"
|
||||||
|
OutcomeForfeit = "F"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GameOutcome represents the result of a single game from a team's perspective.
|
||||||
|
type GameOutcome struct {
|
||||||
|
Type string // One of Outcome* constants: "W", "L", "OTW", "OTL", "D", "F"
|
||||||
|
Opponent *Team // The opposing team (may be nil if relation not loaded)
|
||||||
|
Score string // e.g. "3-1" or "" for forfeits
|
||||||
|
IsForfeit bool // Whether this game was decided by forfeit
|
||||||
|
Fixture *Fixture // The fixture itself
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchPreviewData holds all computed data needed for the match preview tab.
|
||||||
|
type MatchPreviewData struct {
|
||||||
|
HomeRecord *TeamRecord
|
||||||
|
AwayRecord *TeamRecord
|
||||||
|
HomePosition int
|
||||||
|
AwayPosition int
|
||||||
|
TotalTeams int
|
||||||
|
HomeRecentGames []*GameOutcome
|
||||||
|
AwayRecentGames []*GameOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeRecentGames calculates the last N game outcomes for a given team.
|
||||||
|
// Fixtures should be all allocated fixtures for the season+league.
|
||||||
|
// Results should be finalized results mapped by fixture ID.
|
||||||
|
// Schedules should be accepted schedules mapped by fixture ID (for ordering by scheduled time).
|
||||||
|
// The returned outcomes are in chronological order (oldest first, newest last).
|
||||||
|
func ComputeRecentGames(
|
||||||
|
teamID int,
|
||||||
|
fixtures []*Fixture,
|
||||||
|
resultMap map[int]*FixtureResult,
|
||||||
|
scheduleMap map[int]*FixtureSchedule,
|
||||||
|
limit int,
|
||||||
|
) []*GameOutcome {
|
||||||
|
// Collect fixtures involving this team that have finalized results
|
||||||
|
type fixtureWithTime struct {
|
||||||
|
fixture *Fixture
|
||||||
|
result *FixtureResult
|
||||||
|
time time.Time
|
||||||
|
}
|
||||||
|
var played []fixtureWithTime
|
||||||
|
|
||||||
|
for _, f := range fixtures {
|
||||||
|
if f.HomeTeamID != teamID && f.AwayTeamID != teamID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res, ok := resultMap[f.ID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Use schedule time for ordering, fall back to result creation time
|
||||||
|
t := time.Unix(res.CreatedAt, 0)
|
||||||
|
if scheduleMap != nil {
|
||||||
|
if sched, ok := scheduleMap[f.ID]; ok && sched.ScheduledTime != nil {
|
||||||
|
t = *sched.ScheduledTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
played = append(played, fixtureWithTime{fixture: f, result: res, time: t})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by time descending (most recent first)
|
||||||
|
sort.Slice(played, func(i, j int) bool {
|
||||||
|
return played[i].time.After(played[j].time)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Take only the most recent N
|
||||||
|
if len(played) > limit {
|
||||||
|
played = played[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse to chronological order (oldest first)
|
||||||
|
for i, j := 0, len(played)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
played[i], played[j] = played[j], played[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build outcome list
|
||||||
|
outcomes := make([]*GameOutcome, len(played))
|
||||||
|
for i, p := range played {
|
||||||
|
outcomes[i] = buildGameOutcome(teamID, p.fixture, p.result)
|
||||||
|
}
|
||||||
|
return outcomes
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildGameOutcome determines the outcome type for a single game from a team's perspective.
|
||||||
|
// Note: fixtures must have their HomeTeam and AwayTeam relations loaded.
|
||||||
|
func buildGameOutcome(teamID int, fixture *Fixture, result *FixtureResult) *GameOutcome {
|
||||||
|
isHome := fixture.HomeTeamID == teamID
|
||||||
|
var opponent *Team
|
||||||
|
if isHome {
|
||||||
|
opponent = fixture.AwayTeam // may be nil if relation not loaded
|
||||||
|
} else {
|
||||||
|
opponent = fixture.HomeTeam // may be nil if relation not loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome := &GameOutcome{
|
||||||
|
Opponent: opponent,
|
||||||
|
Fixture: fixture,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle forfeits
|
||||||
|
if result.IsForfeit {
|
||||||
|
outcome.IsForfeit = true
|
||||||
|
outcome.Type = OutcomeForfeit
|
||||||
|
if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeMutual {
|
||||||
|
outcome.Type = OutcomeOTLoss // mutual forfeit counts as OT loss for both
|
||||||
|
} else if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeOutright {
|
||||||
|
thisSide := "away"
|
||||||
|
if isHome {
|
||||||
|
thisSide = "home"
|
||||||
|
}
|
||||||
|
if result.ForfeitTeam != nil && *result.ForfeitTeam == thisSide {
|
||||||
|
outcome.Type = OutcomeLoss // this team forfeited
|
||||||
|
} else {
|
||||||
|
outcome.Type = OutcomeWin // opponent forfeited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal match - build score string from this team's perspective
|
||||||
|
if isHome {
|
||||||
|
outcome.Score = fmt.Sprintf("%d-%d", result.HomeScore, result.AwayScore)
|
||||||
|
} else {
|
||||||
|
outcome.Score = fmt.Sprintf("%d-%d", result.AwayScore, result.HomeScore)
|
||||||
|
}
|
||||||
|
|
||||||
|
won := (isHome && result.Winner == "home") || (!isHome && result.Winner == "away")
|
||||||
|
lost := (isHome && result.Winner == "away") || (!isHome && result.Winner == "home")
|
||||||
|
isOT := strings.EqualFold(result.EndReason, "Overtime")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case won && isOT:
|
||||||
|
outcome.Type = OutcomeOTWin
|
||||||
|
case won:
|
||||||
|
outcome.Type = OutcomeWin
|
||||||
|
case lost && isOT:
|
||||||
|
outcome.Type = OutcomeOTLoss
|
||||||
|
case lost:
|
||||||
|
outcome.Type = OutcomeLoss
|
||||||
|
default:
|
||||||
|
outcome.Type = OutcomeDraw
|
||||||
|
}
|
||||||
|
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTeamsForSeasonLeague returns all teams participating in a given season+league.
|
||||||
|
func GetTeamsForSeasonLeague(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Team, error) {
|
||||||
|
var teams []*Team
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&teams).
|
||||||
|
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
|
||||||
|
Where("tp.season_id = ? AND tp.league_id = ?", seasonID, leagueID).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return teams, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeMatchPreview fetches all data needed for the match preview tab:
|
||||||
|
// team standings, positions, and recent game outcomes for both teams.
|
||||||
|
func ComputeMatchPreview(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
fixture *Fixture,
|
||||||
|
) (*MatchPreviewData, error) {
|
||||||
|
if fixture == nil {
|
||||||
|
return nil, errors.New("fixture cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all teams in this season+league
|
||||||
|
allTeams, err := GetTeamsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetTeamsForSeasonLeague")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all allocated fixtures for the season+league
|
||||||
|
allFixtures, err := GetAllocatedFixtures(ctx, tx, fixture.SeasonID, fixture.LeagueID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetAllocatedFixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get finalized results
|
||||||
|
allFixtureIDs := make([]int, len(allFixtures))
|
||||||
|
for i, f := range allFixtures {
|
||||||
|
allFixtureIDs[i] = f.ID
|
||||||
|
}
|
||||||
|
allResultMap, err := GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get accepted schedules for ordering recent games
|
||||||
|
allScheduleMap, err := GetAcceptedSchedulesForFixtures(ctx, tx, allFixtureIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetAcceptedSchedulesForFixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute leaderboard
|
||||||
|
leaderboard := ComputeLeaderboard(allTeams, allFixtures, allResultMap)
|
||||||
|
|
||||||
|
// Extract positions and records for both teams
|
||||||
|
preview := &MatchPreviewData{
|
||||||
|
TotalTeams: len(leaderboard),
|
||||||
|
}
|
||||||
|
for _, entry := range leaderboard {
|
||||||
|
if entry.Team.ID == fixture.HomeTeamID {
|
||||||
|
preview.HomePosition = entry.Position
|
||||||
|
preview.HomeRecord = entry.Record
|
||||||
|
}
|
||||||
|
if entry.Team.ID == fixture.AwayTeamID {
|
||||||
|
preview.AwayPosition = entry.Position
|
||||||
|
preview.AwayRecord = entry.Record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if preview.HomeRecord == nil {
|
||||||
|
preview.HomeRecord = &TeamRecord{}
|
||||||
|
}
|
||||||
|
if preview.AwayRecord == nil {
|
||||||
|
preview.AwayRecord = &TeamRecord{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute recent games (last 5) for each team
|
||||||
|
preview.HomeRecentGames = ComputeRecentGames(
|
||||||
|
fixture.HomeTeamID, allFixtures, allResultMap, allScheduleMap, 5,
|
||||||
|
)
|
||||||
|
preview.AwayRecentGames = ComputeRecentGames(
|
||||||
|
fixture.AwayTeamID, allFixtures, allResultMap, allScheduleMap, 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
return preview, nil
|
||||||
|
}
|
||||||
89
internal/db/migrations/20260305140000_add_forfeit_support.go
Normal file
89
internal/db/migrations/20260305140000_add_forfeit_support.go
Normal 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
98
internal/db/migrations/20260308140000_add_playoffs.go
Normal file
98
internal/db/migrations/20260308140000_add_playoffs.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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 {
|
||||||
|
// Create playoff_brackets table
|
||||||
|
_, err := conn.NewCreateTable().
|
||||||
|
Model((*db.PlayoffBracket)(nil)).
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create playoff_series table
|
||||||
|
_, err = conn.NewCreateTable().
|
||||||
|
Model((*db.PlayoffSeries)(nil)).
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create playoff_matches table
|
||||||
|
_, err = conn.NewCreateTable().
|
||||||
|
Model((*db.PlayoffMatch)(nil)).
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add foreign key for winner_next_series_id
|
||||||
|
_, err = conn.NewRaw(`
|
||||||
|
ALTER TABLE playoff_series
|
||||||
|
ADD CONSTRAINT fk_winner_next_series
|
||||||
|
FOREIGN KEY (winner_next_series_id) REFERENCES playoff_series(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
`).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add foreign key for loser_next_series_id
|
||||||
|
_, err = conn.NewRaw(`
|
||||||
|
ALTER TABLE playoff_series
|
||||||
|
ADD CONSTRAINT fk_loser_next_series
|
||||||
|
FOREIGN KEY (loser_next_series_id) REFERENCES playoff_series(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
`).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// DOWN migration
|
||||||
|
func(ctx context.Context, conn *bun.DB) error {
|
||||||
|
// Drop tables in reverse order (respecting foreign keys)
|
||||||
|
_, err := conn.NewDropTable().
|
||||||
|
Model((*db.PlayoffMatch)(nil)).
|
||||||
|
IfExists().
|
||||||
|
Cascade().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = conn.NewDropTable().
|
||||||
|
Model((*db.PlayoffSeries)(nil)).
|
||||||
|
IfExists().
|
||||||
|
Cascade().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = conn.NewDropTable().
|
||||||
|
Model((*db.PlayoffBracket)(nil)).
|
||||||
|
IfExists().
|
||||||
|
Cascade().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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 {
|
||||||
|
_, err := conn.NewCreateTable().
|
||||||
|
Model((*db.PlayoffSeriesSchedule)(nil)).
|
||||||
|
IfNotExists().
|
||||||
|
ForeignKey(`("series_id") REFERENCES "playoff_series" ("id") ON DELETE CASCADE`).
|
||||||
|
ForeignKey(`("proposed_by_team_id") REFERENCES "teams" ("id")`).
|
||||||
|
ForeignKey(`("accepted_by_team_id") REFERENCES "teams" ("id")`).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index on series_id for faster lookups
|
||||||
|
_, err = conn.NewCreateIndex().
|
||||||
|
Model((*db.PlayoffSeriesSchedule)(nil)).
|
||||||
|
Index("idx_playoff_series_schedules_series_id").
|
||||||
|
Column("series_id").
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index on status for filtering
|
||||||
|
_, err = conn.NewCreateIndex().
|
||||||
|
Model((*db.PlayoffSeriesSchedule)(nil)).
|
||||||
|
Index("idx_playoff_series_schedules_status").
|
||||||
|
Column("status").
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// DOWN migration
|
||||||
|
func(ctx context.Context, conn *bun.DB) error {
|
||||||
|
_, err := conn.NewDropTable().
|
||||||
|
Model((*db.PlayoffSeriesSchedule)(nil)).
|
||||||
|
IfExists().
|
||||||
|
Exec(ctx)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
// 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
|
// discordID, it will automatically link that user to the player
|
||||||
func NewPlayer(ctx context.Context, tx bun.Tx, discordID string, audit *AuditMeta) (*Player, error) {
|
func NewPlayer(ctx context.Context, tx bun.Tx, name, discordID string, audit *AuditMeta) (*Player, error) {
|
||||||
player := &Player{DiscordID: discordID}
|
player := &Player{DiscordID: discordID, Name: name}
|
||||||
user, err := GetUserByDiscordID(ctx, tx, discordID)
|
user, err := GetUserByDiscordID(ctx, tx, discordID)
|
||||||
if err != nil && !IsBadRequest(err) {
|
if err != nil && !IsBadRequest(err) {
|
||||||
return nil, errors.Wrap(err, "GetUserByDiscordID")
|
return nil, errors.Wrap(err, "GetUserByDiscordID")
|
||||||
}
|
}
|
||||||
if user != nil {
|
if user != nil {
|
||||||
player.UserID = &user.ID
|
player.UserID = &user.ID
|
||||||
|
player.Name = user.Username
|
||||||
}
|
}
|
||||||
err = Insert(tx, player).
|
err = Insert(tx, player).
|
||||||
WithAudit(audit, nil).Exec(ctx)
|
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")
|
return errors.Wrap(err, "GetByField")
|
||||||
}
|
}
|
||||||
// Player doesn't exist, create a new one
|
// 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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "NewPlayer")
|
return errors.Wrap(err, "NewPlayer")
|
||||||
}
|
}
|
||||||
@@ -98,6 +99,243 @@ func UpdatePlayerSlapID(ctx context.Context, tx bun.Tx, playerID int, slapID uin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlayerAllTimeStats holds aggregated all-time stats for a single player
|
||||||
|
type PlayerAllTimeStats struct {
|
||||||
|
GamesPlayed int `bun:"games_played"`
|
||||||
|
PeriodsPlayed int `bun:"total_periods_played"`
|
||||||
|
Goals int `bun:"total_goals"`
|
||||||
|
Assists int `bun:"total_assists"`
|
||||||
|
Saves int `bun:"total_saves"`
|
||||||
|
Shots int `bun:"total_shots"`
|
||||||
|
Blocks int `bun:"total_blocks"`
|
||||||
|
Passes int `bun:"total_passes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerAllTimeStats returns aggregated all-time stats for a player
|
||||||
|
// across all finalized fixture results (period 3 totals).
|
||||||
|
func GetPlayerAllTimeStats(ctx context.Context, tx bun.Tx, playerID int) (*PlayerAllTimeStats, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
stats := new(PlayerAllTimeStats)
|
||||||
|
err := tx.NewRaw(`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
|
||||||
|
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
||||||
|
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
||||||
|
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
||||||
|
COALESCE(SUM(frps.saves), 0) AS total_saves,
|
||||||
|
COALESCE(SUM(frps.shots), 0) AS total_shots,
|
||||||
|
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
|
||||||
|
COALESCE(SUM(frps.passes), 0) AS total_passes
|
||||||
|
FROM fixture_result_player_stats frps
|
||||||
|
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
||||||
|
WHERE fr.finalized = true
|
||||||
|
AND frps.player_id = ?
|
||||||
|
AND frps.period_num = 3
|
||||||
|
`, playerID).Scan(ctx, stats)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewRaw")
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerStatsBySeason returns aggregated stats for a player filtered by season.
|
||||||
|
func GetPlayerStatsBySeason(ctx context.Context, tx bun.Tx, playerID, seasonID int) (*PlayerAllTimeStats, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
if seasonID == 0 {
|
||||||
|
return nil, errors.New("seasonID not provided")
|
||||||
|
}
|
||||||
|
stats := new(PlayerAllTimeStats)
|
||||||
|
err := tx.NewRaw(`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
|
||||||
|
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
||||||
|
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
||||||
|
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
||||||
|
COALESCE(SUM(frps.saves), 0) AS total_saves,
|
||||||
|
COALESCE(SUM(frps.shots), 0) AS total_shots,
|
||||||
|
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
|
||||||
|
COALESCE(SUM(frps.passes), 0) AS total_passes
|
||||||
|
FROM fixture_result_player_stats frps
|
||||||
|
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
||||||
|
JOIN fixtures f ON f.id = fr.fixture_id
|
||||||
|
WHERE fr.finalized = true
|
||||||
|
AND frps.player_id = ?
|
||||||
|
AND frps.period_num = 3
|
||||||
|
AND f.season_id = ?
|
||||||
|
`, playerID, seasonID).Scan(ctx, stats)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewRaw")
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerStatsByTeam returns aggregated stats for a player filtered by team.
|
||||||
|
func GetPlayerStatsByTeam(ctx context.Context, tx bun.Tx, playerID, teamID int) (*PlayerAllTimeStats, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
if teamID == 0 {
|
||||||
|
return nil, errors.New("teamID not provided")
|
||||||
|
}
|
||||||
|
stats := new(PlayerAllTimeStats)
|
||||||
|
err := tx.NewRaw(`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
|
||||||
|
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
||||||
|
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
||||||
|
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
||||||
|
COALESCE(SUM(frps.saves), 0) AS total_saves,
|
||||||
|
COALESCE(SUM(frps.shots), 0) AS total_shots,
|
||||||
|
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
|
||||||
|
COALESCE(SUM(frps.passes), 0) AS total_passes
|
||||||
|
FROM fixture_result_player_stats frps
|
||||||
|
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
||||||
|
WHERE fr.finalized = true
|
||||||
|
AND frps.player_id = ?
|
||||||
|
AND frps.period_num = 3
|
||||||
|
AND frps.team_id = ?
|
||||||
|
`, playerID, teamID).Scan(ctx, stats)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewRaw")
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerTeamInfo holds a team the player has played on and how many seasons
|
||||||
|
type PlayerTeamInfo struct {
|
||||||
|
Team *Team
|
||||||
|
SeasonsCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerTeams returns all teams the player has been rostered on,
|
||||||
|
// with a count of distinct seasons per team.
|
||||||
|
func GetPlayerTeams(ctx context.Context, tx bun.Tx, playerID int) ([]*PlayerTeamInfo, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
type teamRow struct {
|
||||||
|
TeamID int `bun:"team_id"`
|
||||||
|
SeasonsCount int `bun:"seasons_count"`
|
||||||
|
Name string `bun:"name"`
|
||||||
|
ShortName string `bun:"short_name"`
|
||||||
|
AltShortName string `bun:"alt_short_name"`
|
||||||
|
Color string `bun:"color"`
|
||||||
|
}
|
||||||
|
var rows []teamRow
|
||||||
|
err := tx.NewRaw(`
|
||||||
|
SELECT
|
||||||
|
t.id AS team_id,
|
||||||
|
t.name AS name,
|
||||||
|
t.short_name AS short_name,
|
||||||
|
t.alt_short_name AS alt_short_name,
|
||||||
|
t.color AS color,
|
||||||
|
COUNT(DISTINCT tr.season_id) AS seasons_count
|
||||||
|
FROM team_rosters tr
|
||||||
|
JOIN teams t ON t.id = tr.team_id
|
||||||
|
WHERE tr.player_id = ?
|
||||||
|
GROUP BY t.id, t.name, t.short_name, t.alt_short_name, t.color
|
||||||
|
ORDER BY seasons_count DESC
|
||||||
|
`, playerID).Scan(ctx, &rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewRaw")
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []*PlayerTeamInfo
|
||||||
|
for _, row := range rows {
|
||||||
|
results = append(results, &PlayerTeamInfo{
|
||||||
|
Team: &Team{
|
||||||
|
ID: row.TeamID,
|
||||||
|
Name: row.Name,
|
||||||
|
ShortName: row.ShortName,
|
||||||
|
AltShortName: row.AltShortName,
|
||||||
|
Color: row.Color,
|
||||||
|
},
|
||||||
|
SeasonsCount: row.SeasonsCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerSeasonInfo holds info about a player's participation in a specific season
|
||||||
|
type PlayerSeasonInfo struct {
|
||||||
|
Season *Season
|
||||||
|
League *League
|
||||||
|
Team *Team
|
||||||
|
IsManager bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerSeasons returns all season/league/team combos the player has been rostered in.
|
||||||
|
func GetPlayerSeasons(ctx context.Context, tx bun.Tx, playerID int) ([]*PlayerSeasonInfo, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
var rosters []*TeamRoster
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&rosters).
|
||||||
|
Where("tr.player_id = ?", playerID).
|
||||||
|
Relation("Season").
|
||||||
|
Relation("League").
|
||||||
|
Relation("Team").
|
||||||
|
Order("season.start_date DESC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []*PlayerSeasonInfo
|
||||||
|
for _, r := range rosters {
|
||||||
|
results = append(results, &PlayerSeasonInfo{
|
||||||
|
Season: r.Season,
|
||||||
|
League: r.League,
|
||||||
|
Team: r.Team,
|
||||||
|
IsManager: r.IsManager,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerSeasonsList returns distinct seasons the player has participated in (for filter dropdowns).
|
||||||
|
func GetPlayerSeasonsList(ctx context.Context, tx bun.Tx, playerID int) ([]*Season, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
var seasons []*Season
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&seasons).
|
||||||
|
Join("JOIN team_rosters tr ON tr.season_id = s.id").
|
||||||
|
Where("tr.player_id = ?", playerID).
|
||||||
|
GroupExpr("s.id").
|
||||||
|
Order("s.start_date DESC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return seasons, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayerTeamsList returns distinct teams the player has played on (for filter dropdowns).
|
||||||
|
func GetPlayerTeamsList(ctx context.Context, tx bun.Tx, playerID int) ([]*Team, error) {
|
||||||
|
if playerID == 0 {
|
||||||
|
return nil, errors.New("playerID not provided")
|
||||||
|
}
|
||||||
|
var teams []*Team
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&teams).
|
||||||
|
Join("JOIN team_rosters tr ON tr.team_id = t.id").
|
||||||
|
Where("tr.player_id = ?", playerID).
|
||||||
|
GroupExpr("t.id").
|
||||||
|
Order("t.name ASC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return teams, nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetPlayersNotOnTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Player, error) {
|
func GetPlayersNotOnTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Player, error) {
|
||||||
players, err := GetList[Player](tx).Relation("User").
|
players, err := GetList[Player](tx).Relation("User").
|
||||||
Join("LEFT JOIN team_rosters tr ON tr.player_id = p.id").
|
Join("LEFT JOIN team_rosters tr ON tr.player_id = p.id").
|
||||||
|
|||||||
547
internal/db/playoff.go
Normal file
547
internal/db/playoff.go
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlayoffFormat represents the bracket format based on team count
|
||||||
|
type PlayoffFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PlayoffFormat5to6 is for 5-6 teams: top 5 qualify, double-elimination style
|
||||||
|
PlayoffFormat5to6 PlayoffFormat = "5-6-teams"
|
||||||
|
// PlayoffFormat7to9 is for 7-9 teams: top 6 qualify, seeded bracket
|
||||||
|
PlayoffFormat7to9 PlayoffFormat = "7-9-teams"
|
||||||
|
// PlayoffFormat10to15 is for 10-15 teams: top 8 qualify
|
||||||
|
PlayoffFormat10to15 PlayoffFormat = "10-15-teams"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlayoffStatus represents the current state of a playoff bracket
|
||||||
|
type PlayoffStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PlayoffStatusUpcoming PlayoffStatus = "upcoming"
|
||||||
|
PlayoffStatusInProgress PlayoffStatus = "in_progress"
|
||||||
|
PlayoffStatusCompleted PlayoffStatus = "completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeriesStatus represents the current state of a playoff series
|
||||||
|
type SeriesStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SeriesStatusPending SeriesStatus = "pending"
|
||||||
|
SeriesStatusInProgress SeriesStatus = "in_progress"
|
||||||
|
SeriesStatusCompleted SeriesStatus = "completed"
|
||||||
|
SeriesStatusBye SeriesStatus = "bye"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlayoffBracket is the top-level container for a league's playoff bracket
|
||||||
|
type PlayoffBracket struct {
|
||||||
|
bun.BaseModel `bun:"table:playoff_brackets,alias:pb"`
|
||||||
|
|
||||||
|
ID int `bun:"id,pk,autoincrement"`
|
||||||
|
SeasonID int `bun:",notnull,unique:season_league"`
|
||||||
|
LeagueID int `bun:",notnull,unique:season_league"`
|
||||||
|
Format PlayoffFormat `bun:",notnull"`
|
||||||
|
Status PlayoffStatus `bun:",notnull,default:'upcoming'"`
|
||||||
|
CreatedAt int64 `bun:",notnull"`
|
||||||
|
UpdatedAt *int64 `bun:"updated_at"`
|
||||||
|
|
||||||
|
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
|
||||||
|
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
||||||
|
Series []*PlayoffSeries `bun:"rel:has-many,join:id=bracket_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayoffSeries represents a single matchup (potentially best-of-N) in the bracket
|
||||||
|
type PlayoffSeries struct {
|
||||||
|
bun.BaseModel `bun:"table:playoff_series,alias:ps"`
|
||||||
|
|
||||||
|
ID int `bun:"id,pk,autoincrement"`
|
||||||
|
BracketID int `bun:",notnull"`
|
||||||
|
SeriesNumber int `bun:",notnull"` // Display order within bracket
|
||||||
|
Round string `bun:",notnull"` // e.g. "qualifying_final", "semi_final", "grand_final"
|
||||||
|
Label string `bun:",notnull"` // Human-readable label e.g. "QF1", "SF2", "Grand Final"
|
||||||
|
Team1ID *int `bun:"team1_id"`
|
||||||
|
Team2ID *int `bun:"team2_id"`
|
||||||
|
Team1Seed *int `bun:"team1_seed"` // Original seeding position (1st, 2nd, etc.)
|
||||||
|
Team2Seed *int `bun:"team2_seed"` // Original seeding position
|
||||||
|
WinnerTeamID *int `bun:"winner_team_id"` // Set when series is decided
|
||||||
|
LoserTeamID *int `bun:"loser_team_id"` // Set when series is decided
|
||||||
|
MatchesToWin int `bun:",notnull,default:1"` // 1 = single match, 2 = Bo3, 3 = Bo5, etc.
|
||||||
|
Team1Wins int `bun:",notnull,default:0"` // Matches won by team1
|
||||||
|
Team2Wins int `bun:",notnull,default:0"` // Matches won by team2
|
||||||
|
Status SeriesStatus `bun:",notnull,default:'pending'"` // pending, in_progress, completed, bye
|
||||||
|
WinnerNextID *int `bun:"winner_next_series_id"` // Series the winner advances to
|
||||||
|
WinnerNextSlot *string `bun:"winner_next_slot"` // "team1" or "team2" in next series
|
||||||
|
LoserNextID *int `bun:"loser_next_series_id"` // Series the loser drops to (double-elim)
|
||||||
|
LoserNextSlot *string `bun:"loser_next_slot"` // "team1" or "team2" in loser's next series
|
||||||
|
CreatedAt int64 `bun:",notnull"`
|
||||||
|
|
||||||
|
Bracket *PlayoffBracket `bun:"rel:belongs-to,join:bracket_id=id"`
|
||||||
|
Team1 *Team `bun:"rel:belongs-to,join:team1_id=id"`
|
||||||
|
Team2 *Team `bun:"rel:belongs-to,join:team2_id=id"`
|
||||||
|
Winner *Team `bun:"rel:belongs-to,join:winner_team_id=id"`
|
||||||
|
Loser *Team `bun:"rel:belongs-to,join:loser_team_id=id"`
|
||||||
|
Matches []*PlayoffMatch `bun:"rel:has-many,join:id=series_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayoffMatch represents a single game within a series
|
||||||
|
type PlayoffMatch struct {
|
||||||
|
bun.BaseModel `bun:"table:playoff_matches,alias:pm"`
|
||||||
|
|
||||||
|
ID int `bun:"id,pk,autoincrement"`
|
||||||
|
SeriesID int `bun:",notnull"`
|
||||||
|
MatchNumber int `bun:",notnull"` // 1-indexed: game 1, game 2, etc.
|
||||||
|
HomeTeamID *int `bun:"home_team_id"`
|
||||||
|
AwayTeamID *int `bun:"away_team_id"`
|
||||||
|
FixtureID *int `bun:"fixture_id"` // Links to existing fixture system
|
||||||
|
Status string `bun:",notnull,default:'pending'"`
|
||||||
|
CreatedAt int64 `bun:",notnull"`
|
||||||
|
|
||||||
|
Series *PlayoffSeries `bun:"rel:belongs-to,join:series_id=id"`
|
||||||
|
Home *Team `bun:"rel:belongs-to,join:home_team_id=id"`
|
||||||
|
Away *Team `bun:"rel:belongs-to,join:away_team_id=id"`
|
||||||
|
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlayoffBracket creates a new playoff bracket for a season+league
|
||||||
|
func NewPlayoffBracket(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID int,
|
||||||
|
format PlayoffFormat,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) (*PlayoffBracket, error) {
|
||||||
|
bracket := &PlayoffBracket{
|
||||||
|
SeasonID: seasonID,
|
||||||
|
LeagueID: leagueID,
|
||||||
|
Format: format,
|
||||||
|
Status: PlayoffStatusUpcoming,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
err := Insert(tx, bracket).WithAudit(audit, &AuditInfo{
|
||||||
|
Action: "playoffs.create_bracket",
|
||||||
|
ResourceType: "playoff_bracket",
|
||||||
|
ResourceID: nil,
|
||||||
|
Details: map[string]any{
|
||||||
|
"season_id": seasonID,
|
||||||
|
"league_id": leagueID,
|
||||||
|
"format": string(format),
|
||||||
|
},
|
||||||
|
}).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "Insert")
|
||||||
|
}
|
||||||
|
return bracket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayoffBracket retrieves a playoff bracket for a season+league with all series and teams
|
||||||
|
func GetPlayoffBracket(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID int,
|
||||||
|
) (*PlayoffBracket, error) {
|
||||||
|
bracket := new(PlayoffBracket)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(bracket).
|
||||||
|
Where("pb.season_id = ?", seasonID).
|
||||||
|
Where("pb.league_id = ?", leagueID).
|
||||||
|
Relation("Season").
|
||||||
|
Relation("League").
|
||||||
|
Relation("Series", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Order("ps.series_number ASC")
|
||||||
|
}).
|
||||||
|
Relation("Series.Team1").
|
||||||
|
Relation("Series.Team2").
|
||||||
|
Relation("Series.Winner").
|
||||||
|
Relation("Series.Loser").
|
||||||
|
Relation("Series.Matches", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Order("pm.match_number ASC")
|
||||||
|
}).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "sql: no rows in result set" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return bracket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayoffBracketByID retrieves a playoff bracket by ID with all series
|
||||||
|
func GetPlayoffBracketByID(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
bracketID int,
|
||||||
|
) (*PlayoffBracket, error) {
|
||||||
|
return GetByID[PlayoffBracket](tx, bracketID).
|
||||||
|
Relation("Season").
|
||||||
|
Relation("League").
|
||||||
|
Relation("Series", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Order("ps.series_number ASC")
|
||||||
|
}).
|
||||||
|
Relation("Series.Team1").
|
||||||
|
Relation("Series.Team2").
|
||||||
|
Relation("Series.Winner").
|
||||||
|
Relation("Series.Loser").
|
||||||
|
Relation("Series.Matches", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Order("pm.match_number ASC")
|
||||||
|
}).
|
||||||
|
Get(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlayoffSeries creates a new series within a bracket
|
||||||
|
func NewPlayoffSeries(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
bracket *PlayoffBracket,
|
||||||
|
seriesNumber int,
|
||||||
|
round, label string,
|
||||||
|
team1ID, team2ID *int,
|
||||||
|
team1Seed, team2Seed *int,
|
||||||
|
matchesToWin int,
|
||||||
|
status SeriesStatus,
|
||||||
|
) (*PlayoffSeries, error) {
|
||||||
|
series := &PlayoffSeries{
|
||||||
|
BracketID: bracket.ID,
|
||||||
|
SeriesNumber: seriesNumber,
|
||||||
|
Round: round,
|
||||||
|
Label: label,
|
||||||
|
Team1ID: team1ID,
|
||||||
|
Team2ID: team2ID,
|
||||||
|
Team1Seed: team1Seed,
|
||||||
|
Team2Seed: team2Seed,
|
||||||
|
MatchesToWin: matchesToWin,
|
||||||
|
Status: status,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
err := Insert(tx, series).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "Insert")
|
||||||
|
}
|
||||||
|
return series, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSeriesAdvancement sets the advancement links for a series
|
||||||
|
func SetSeriesAdvancement(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seriesID int,
|
||||||
|
winnerNextID *int,
|
||||||
|
winnerNextSlot *string,
|
||||||
|
loserNextID *int,
|
||||||
|
loserNextSlot *string,
|
||||||
|
) error {
|
||||||
|
_, err := tx.NewUpdate().
|
||||||
|
Model((*PlayoffSeries)(nil)).
|
||||||
|
Set("winner_next_series_id = ?", winnerNextID).
|
||||||
|
Set("winner_next_slot = ?", winnerNextSlot).
|
||||||
|
Set("loser_next_series_id = ?", loserNextID).
|
||||||
|
Set("loser_next_slot = ?", loserNextSlot).
|
||||||
|
Where("id = ?", seriesID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewUpdate")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUnplayedFixtures counts fixtures without finalized results for a season+league
|
||||||
|
func CountUnplayedFixtures(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID int,
|
||||||
|
) (int, error) {
|
||||||
|
count, err := tx.NewSelect().
|
||||||
|
Model((*Fixture)(nil)).
|
||||||
|
Where("f.season_id = ?", seasonID).
|
||||||
|
Where("f.league_id = ?", leagueID).
|
||||||
|
Where("f.game_week IS NOT NULL").
|
||||||
|
Where("NOT EXISTS (SELECT 1 FROM fixture_results fr WHERE fr.fixture_id = f.id AND fr.finalized = true)").
|
||||||
|
Count(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "tx.NewSelect.Count")
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUnplayedFixtures returns all fixtures without finalized results for a season+league
|
||||||
|
func GetUnplayedFixtures(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID int,
|
||||||
|
) ([]*Fixture, error) {
|
||||||
|
fixtures, err := GetList[Fixture](tx).
|
||||||
|
Where("f.season_id = ?", seasonID).
|
||||||
|
Where("f.league_id = ?", leagueID).
|
||||||
|
Where("f.game_week IS NOT NULL").
|
||||||
|
Where("NOT EXISTS (SELECT 1 FROM fixture_results fr WHERE fr.fixture_id = f.id AND fr.finalized = true)").
|
||||||
|
Order("f.game_week ASC", "f.round ASC", "f.id ASC").
|
||||||
|
Relation("HomeTeam").
|
||||||
|
Relation("AwayTeam").
|
||||||
|
GetAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetList")
|
||||||
|
}
|
||||||
|
return fixtures, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoForfeitUnplayedFixtures creates mutual forfeit results for all unplayed fixtures
|
||||||
|
func AutoForfeitUnplayedFixtures(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID int,
|
||||||
|
userID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) (int, error) {
|
||||||
|
unplayed, err := GetUnplayedFixtures(ctx, tx, seasonID, leagueID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "GetUnplayedFixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := "Auto-forfeited: regular season ended for finals"
|
||||||
|
for _, fixture := range unplayed {
|
||||||
|
// Check if a result already exists (non-finalized)
|
||||||
|
existing, err := GetFixtureResult(ctx, tx, fixture.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "GetFixtureResult")
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
// Skip fixtures that already have any result
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = CreateForfeitResult(ctx, tx, fixture,
|
||||||
|
ForfeitTypeMutual, "", reason, userID, audit)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "CreateForfeitResult")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(unplayed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayoffSeriesByID retrieves a single playoff series with all relations needed
|
||||||
|
// for the series detail page.
|
||||||
|
func GetPlayoffSeriesByID(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seriesID int,
|
||||||
|
) (*PlayoffSeries, error) {
|
||||||
|
series := new(PlayoffSeries)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(series).
|
||||||
|
Where("ps.id = ?", seriesID).
|
||||||
|
Relation("Bracket").
|
||||||
|
Relation("Bracket.Season").
|
||||||
|
Relation("Bracket.League").
|
||||||
|
Relation("Bracket.Series").
|
||||||
|
Relation("Team1").
|
||||||
|
Relation("Team2").
|
||||||
|
Relation("Winner").
|
||||||
|
Relation("Loser").
|
||||||
|
Relation("Matches", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Order("pm.match_number ASC")
|
||||||
|
}).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "sql: no rows in result set" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return series, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanScheduleSeries checks if the user is a manager of one of the teams in the series.
|
||||||
|
// Returns (canSchedule, teamID) where teamID is the team the user manages (0 if not a manager).
|
||||||
|
// Both teams must be assigned for scheduling to be possible.
|
||||||
|
func CanScheduleSeries(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
series *PlayoffSeries,
|
||||||
|
user *User,
|
||||||
|
) (bool, int, error) {
|
||||||
|
if user == nil || user.Player == nil {
|
||||||
|
return false, 0, nil
|
||||||
|
}
|
||||||
|
if series.Team1ID == nil || series.Team2ID == nil {
|
||||||
|
return false, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
roster := new(TeamRoster)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(roster).
|
||||||
|
Column("team_id", "is_manager").
|
||||||
|
Where("team_id IN (?)", bun.In([]int{*series.Team1ID, *series.Team2ID})).
|
||||||
|
Where("season_id = ?", series.Bracket.SeasonID).
|
||||||
|
Where("league_id = ?", series.Bracket.LeagueID).
|
||||||
|
Where("player_id = ?", user.Player.ID).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "sql: no rows in result set" {
|
||||||
|
return false, 0, nil
|
||||||
|
}
|
||||||
|
return false, 0, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
if !roster.IsManager {
|
||||||
|
return false, 0, nil
|
||||||
|
}
|
||||||
|
return true, roster.TeamID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSeriesTeamRosters returns rosters for both teams in a series.
|
||||||
|
// Returns map["team1"|"team2"] -> []*PlayerWithPlayStatus
|
||||||
|
func GetSeriesTeamRosters(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
series *PlayoffSeries,
|
||||||
|
) (map[string][]*PlayerWithPlayStatus, error) {
|
||||||
|
if series == nil {
|
||||||
|
return nil, errors.New("series cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
rosters := map[string][]*PlayerWithPlayStatus{}
|
||||||
|
|
||||||
|
if series.Team1ID != nil {
|
||||||
|
team1Rosters := []*TeamRoster{}
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&team1Rosters).
|
||||||
|
Where("tr.team_id = ?", *series.Team1ID).
|
||||||
|
Where("tr.season_id = ?", series.Bracket.SeasonID).
|
||||||
|
Where("tr.league_id = ?", series.Bracket.LeagueID).
|
||||||
|
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Relation("User")
|
||||||
|
}).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect team1 roster")
|
||||||
|
}
|
||||||
|
for _, tr := range team1Rosters {
|
||||||
|
rosters["team1"] = append(rosters["team1"], &PlayerWithPlayStatus{
|
||||||
|
Player: tr.Player,
|
||||||
|
Played: false,
|
||||||
|
IsManager: tr.IsManager,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if series.Team2ID != nil {
|
||||||
|
team2Rosters := []*TeamRoster{}
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&team2Rosters).
|
||||||
|
Where("tr.team_id = ?", *series.Team2ID).
|
||||||
|
Where("tr.season_id = ?", series.Bracket.SeasonID).
|
||||||
|
Where("tr.league_id = ?", series.Bracket.LeagueID).
|
||||||
|
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Relation("User")
|
||||||
|
}).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect team2 roster")
|
||||||
|
}
|
||||||
|
for _, tr := range team2Rosters {
|
||||||
|
rosters["team2"] = append(rosters["team2"], &PlayerWithPlayStatus{
|
||||||
|
Player: tr.Player,
|
||||||
|
Played: false,
|
||||||
|
IsManager: tr.IsManager,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rosters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeSeriesPreview computes standings comparison data for the two teams in a series.
|
||||||
|
// Uses the same logic as ComputeMatchPreview but takes a series instead of a fixture.
|
||||||
|
func ComputeSeriesPreview(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
series *PlayoffSeries,
|
||||||
|
) (*MatchPreviewData, error) {
|
||||||
|
if series == nil || series.Bracket == nil {
|
||||||
|
return nil, errors.New("series and bracket cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
seasonID := series.Bracket.SeasonID
|
||||||
|
leagueID := series.Bracket.LeagueID
|
||||||
|
|
||||||
|
// Get all teams in this season+league
|
||||||
|
allTeams, err := GetTeamsForSeasonLeague(ctx, tx, seasonID, leagueID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetTeamsForSeasonLeague")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all allocated fixtures for the season+league
|
||||||
|
allFixtures, err := GetAllocatedFixtures(ctx, tx, seasonID, 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)
|
||||||
|
|
||||||
|
preview := &MatchPreviewData{
|
||||||
|
TotalTeams: len(leaderboard),
|
||||||
|
}
|
||||||
|
|
||||||
|
team1ID := 0
|
||||||
|
team2ID := 0
|
||||||
|
if series.Team1ID != nil {
|
||||||
|
team1ID = *series.Team1ID
|
||||||
|
}
|
||||||
|
if series.Team2ID != nil {
|
||||||
|
team2ID = *series.Team2ID
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range leaderboard {
|
||||||
|
if entry.Team.ID == team1ID {
|
||||||
|
preview.HomePosition = entry.Position
|
||||||
|
preview.HomeRecord = entry.Record
|
||||||
|
}
|
||||||
|
if entry.Team.ID == team2ID {
|
||||||
|
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
|
||||||
|
if team1ID > 0 {
|
||||||
|
preview.HomeRecentGames = ComputeRecentGames(
|
||||||
|
team1ID, allFixtures, allResultMap, allScheduleMap, 5,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if team2ID > 0 {
|
||||||
|
preview.AwayRecentGames = ComputeRecentGames(
|
||||||
|
team2ID, allFixtures, allResultMap, allScheduleMap, 5,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return preview, nil
|
||||||
|
}
|
||||||
533
internal/db/playoff_generation.go
Normal file
533
internal/db/playoff_generation.go
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeneratePlayoffBracket creates a complete bracket structure from the leaderboard.
|
||||||
|
// It creates the bracket, all series with advancement links, but no individual
|
||||||
|
// matches (those are created when results are recorded).
|
||||||
|
// roundFormats maps round names (e.g. "grand_final") to matches_to_win values
|
||||||
|
// (1 = BO1, 2 = BO3, 3 = BO5). Rounds not in the map default to BO1.
|
||||||
|
func GeneratePlayoffBracket(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID int,
|
||||||
|
format PlayoffFormat,
|
||||||
|
leaderboard []*LeaderboardEntry,
|
||||||
|
roundFormats map[string]int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) (*PlayoffBracket, error) {
|
||||||
|
// Validate format and team count
|
||||||
|
if err := validateFormatTeamCount(format, len(leaderboard)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check no bracket already exists
|
||||||
|
existing, err := GetPlayoffBracket(ctx, tx, seasonID, leagueID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetPlayoffBracket")
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return nil, BadRequest("playoff bracket already exists for this season and league")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the bracket
|
||||||
|
bracket, err := NewPlayoffBracket(ctx, tx, seasonID, leagueID, format, audit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "NewPlayoffBracket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate series based on format
|
||||||
|
switch format {
|
||||||
|
case PlayoffFormat5to6:
|
||||||
|
err = generate5to6Bracket(ctx, tx, bracket, leaderboard, roundFormats)
|
||||||
|
case PlayoffFormat7to9:
|
||||||
|
err = generate7to9Bracket(ctx, tx, bracket, leaderboard, roundFormats)
|
||||||
|
case PlayoffFormat10to15:
|
||||||
|
err = generate10to15Bracket(ctx, tx, bracket, leaderboard, roundFormats)
|
||||||
|
default:
|
||||||
|
return nil, BadRequest(fmt.Sprintf("unknown playoff format: %s", format))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "generateBracket")
|
||||||
|
}
|
||||||
|
|
||||||
|
return bracket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFormatTeamCount(format PlayoffFormat, teamCount int) error {
|
||||||
|
switch format {
|
||||||
|
case PlayoffFormat5to6:
|
||||||
|
if teamCount < 5 {
|
||||||
|
return BadRequest(
|
||||||
|
fmt.Sprintf("5-6 team format requires at least 5 teams, got %d", teamCount))
|
||||||
|
}
|
||||||
|
case PlayoffFormat7to9:
|
||||||
|
if teamCount < 7 {
|
||||||
|
return BadRequest(
|
||||||
|
fmt.Sprintf("7-9 team format requires at least 7 teams, got %d", teamCount))
|
||||||
|
}
|
||||||
|
case PlayoffFormat10to15:
|
||||||
|
if teamCount < 10 {
|
||||||
|
return BadRequest(
|
||||||
|
fmt.Sprintf("10-15 team format requires at least 10 teams, got %d", teamCount))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return BadRequest(fmt.Sprintf("unknown playoff format: %s", format))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// intPtr is a helper to create a pointer to an int
|
||||||
|
func intPtr(i int) *int {
|
||||||
|
return &i
|
||||||
|
}
|
||||||
|
|
||||||
|
// strPtr is a helper to create a pointer to a string
|
||||||
|
func strPtr(s string) *string {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMatchesToWin looks up the matches_to_win value for a round from the config map.
|
||||||
|
// Returns 1 (BO1) if the round is not in the map or the value is invalid.
|
||||||
|
func getMatchesToWin(roundFormats map[string]int, round string) int {
|
||||||
|
if roundFormats == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if v, ok := roundFormats[round]; ok && v >= 1 && v <= 3 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate5to6Bracket creates:
|
||||||
|
//
|
||||||
|
// Round 1:
|
||||||
|
// S1 (Upper Bracket): 2nd vs 3rd
|
||||||
|
// S2 (Lower Bracket): 4th vs 5th
|
||||||
|
// Round 2:
|
||||||
|
// S3 (Upper Final): 1st vs Winner(S1) — second chance for both
|
||||||
|
// S4 (Lower Final): Loser(S3) vs Winner(S2)
|
||||||
|
// Round 3:
|
||||||
|
// S5 (Grand Final): Winner(S3) vs Winner(S4)
|
||||||
|
func generate5to6Bracket(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
bracket *PlayoffBracket,
|
||||||
|
leaderboard []*LeaderboardEntry,
|
||||||
|
roundFormats map[string]int,
|
||||||
|
) error {
|
||||||
|
seed1 := leaderboard[0]
|
||||||
|
seed2 := leaderboard[1]
|
||||||
|
seed3 := leaderboard[2]
|
||||||
|
seed4 := leaderboard[3]
|
||||||
|
seed5 := leaderboard[4]
|
||||||
|
|
||||||
|
// S1: Upper Bracket - 2nd vs 3rd
|
||||||
|
s1, err := NewPlayoffSeries(ctx, tx, bracket, 1,
|
||||||
|
"upper_bracket", "Upper Bracket",
|
||||||
|
&seed2.Team.ID, &seed3.Team.ID,
|
||||||
|
intPtr(2), intPtr(3),
|
||||||
|
getMatchesToWin(roundFormats, "upper_bracket"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create S1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S2: Lower Bracket - 4th vs 5th (elimination)
|
||||||
|
s2, err := NewPlayoffSeries(ctx, tx, bracket, 2,
|
||||||
|
"lower_bracket", "Lower Bracket",
|
||||||
|
&seed4.Team.ID, &seed5.Team.ID,
|
||||||
|
intPtr(4), intPtr(5),
|
||||||
|
getMatchesToWin(roundFormats, "lower_bracket"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create S2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3: Upper Final - 1st vs Winner(S1)
|
||||||
|
s3, err := NewPlayoffSeries(ctx, tx, bracket, 3,
|
||||||
|
"upper_final", "Upper Final",
|
||||||
|
&seed1.Team.ID, nil, // team2 filled by S1 winner
|
||||||
|
intPtr(1), nil,
|
||||||
|
getMatchesToWin(roundFormats, "upper_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create S3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S4: Lower Final - Loser(S3) vs Winner(S2)
|
||||||
|
s4, err := NewPlayoffSeries(ctx, tx, bracket, 4,
|
||||||
|
"lower_final", "Lower Final",
|
||||||
|
nil, nil, // team1 = Loser(S3), team2 = Winner(S2)
|
||||||
|
nil, nil,
|
||||||
|
getMatchesToWin(roundFormats, "lower_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create S4")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S5: Grand Final - Winner(S3) vs Winner(S4)
|
||||||
|
s5, err := NewPlayoffSeries(ctx, tx, bracket, 5,
|
||||||
|
"grand_final", "Grand Final",
|
||||||
|
nil, nil,
|
||||||
|
nil, nil,
|
||||||
|
getMatchesToWin(roundFormats, "grand_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create S5")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up advancement
|
||||||
|
// S1: Winner -> S3 (team2), no loser advancement (eliminated)
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, s1.ID,
|
||||||
|
&s3.ID, strPtr("team2"), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire S1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S2: Winner -> S4 (team2), no loser advancement (eliminated)
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, s2.ID,
|
||||||
|
&s4.ID, strPtr("team2"), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire S2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3: Winner -> S5 (team1), Loser -> S4 (team1) — second chance
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, s3.ID,
|
||||||
|
&s5.ID, strPtr("team1"), &s4.ID, strPtr("team1"))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire S3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S4: Winner -> S5 (team2), no loser advancement (eliminated)
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, s4.ID,
|
||||||
|
&s5.ID, strPtr("team2"), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire S4")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S5: Grand Final - no advancement
|
||||||
|
_ = s5
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate7to9Bracket creates:
|
||||||
|
//
|
||||||
|
// Quarter Finals:
|
||||||
|
// S1 (QF1): 3rd vs 6th
|
||||||
|
// S2 (QF2): 4th vs 5th
|
||||||
|
// Semi Finals:
|
||||||
|
// S3 (SF1): 1st vs Winner(S1)
|
||||||
|
// S4 (SF2): 2nd vs Winner(S2)
|
||||||
|
// Third Place Playoff:
|
||||||
|
// S5: Loser(S3) vs Loser(S4)
|
||||||
|
// Grand Final:
|
||||||
|
// S6: Winner(S3) vs Winner(S4)
|
||||||
|
func generate7to9Bracket(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
bracket *PlayoffBracket,
|
||||||
|
leaderboard []*LeaderboardEntry,
|
||||||
|
roundFormats map[string]int,
|
||||||
|
) error {
|
||||||
|
seed1 := leaderboard[0]
|
||||||
|
seed2 := leaderboard[1]
|
||||||
|
seed3 := leaderboard[2]
|
||||||
|
seed4 := leaderboard[3]
|
||||||
|
seed5 := leaderboard[4]
|
||||||
|
seed6 := leaderboard[5]
|
||||||
|
|
||||||
|
// S1: QF1 - 3rd vs 6th
|
||||||
|
s1, err := NewPlayoffSeries(ctx, tx, bracket, 1,
|
||||||
|
"quarter_final", "QF1",
|
||||||
|
&seed3.Team.ID, &seed6.Team.ID,
|
||||||
|
intPtr(3), intPtr(6),
|
||||||
|
getMatchesToWin(roundFormats, "quarter_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create S1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S2: QF2 - 4th vs 5th
|
||||||
|
s2, err := NewPlayoffSeries(ctx, tx, bracket, 2,
|
||||||
|
"quarter_final", "QF2",
|
||||||
|
&seed4.Team.ID, &seed5.Team.ID,
|
||||||
|
intPtr(4), intPtr(5),
|
||||||
|
getMatchesToWin(roundFormats, "quarter_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create S2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3: SF1 - 1st vs Winner(QF1)
|
||||||
|
s3, err := NewPlayoffSeries(ctx, tx, bracket, 3,
|
||||||
|
"semi_final", "SF1",
|
||||||
|
&seed1.Team.ID, nil,
|
||||||
|
intPtr(1), nil,
|
||||||
|
getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create S3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S4: SF2 - 2nd vs Winner(QF2)
|
||||||
|
s4, err := NewPlayoffSeries(ctx, tx, bracket, 4,
|
||||||
|
"semi_final", "SF2",
|
||||||
|
&seed2.Team.ID, nil,
|
||||||
|
intPtr(2), nil,
|
||||||
|
getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create S4")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S5: Third Place Playoff - Loser(SF1) vs Loser(SF2)
|
||||||
|
s5, err := NewPlayoffSeries(ctx, tx, bracket, 5,
|
||||||
|
"third_place", "Third Place Playoff",
|
||||||
|
nil, nil,
|
||||||
|
nil, nil,
|
||||||
|
getMatchesToWin(roundFormats, "third_place"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create S5")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S6: Grand Final - Winner(SF1) vs Winner(SF2)
|
||||||
|
s6, err := NewPlayoffSeries(ctx, tx, bracket, 6,
|
||||||
|
"grand_final", "Grand Final",
|
||||||
|
nil, nil,
|
||||||
|
nil, nil,
|
||||||
|
getMatchesToWin(roundFormats, "grand_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create S6")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up advancement
|
||||||
|
// S1 QF1: Winner -> S3 SF1 (team2)
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, s1.ID,
|
||||||
|
&s3.ID, strPtr("team2"), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire S1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S2 QF2: Winner -> S4 SF2 (team2)
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, s2.ID,
|
||||||
|
&s4.ID, strPtr("team2"), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire S2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3 SF1: Winner -> S6 GF (team1), Loser -> S5 3rd Place (team1)
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, s3.ID,
|
||||||
|
&s6.ID, strPtr("team1"), &s5.ID, strPtr("team1"))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire S3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S4 SF2: Winner -> S6 GF (team2), Loser -> S5 3rd Place (team2)
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, s4.ID,
|
||||||
|
&s6.ID, strPtr("team2"), &s5.ID, strPtr("team2"))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire S4")
|
||||||
|
}
|
||||||
|
|
||||||
|
// S5 and S6 are terminal — no advancement
|
||||||
|
_ = s5
|
||||||
|
_ = s6
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate10to15Bracket creates a finals bracket for 10-15 teams:
|
||||||
|
//
|
||||||
|
// Qualifying Finals: Top 4 get second chance
|
||||||
|
// QF1: 1st vs 4th
|
||||||
|
// QF2: 2nd vs 3rd
|
||||||
|
//
|
||||||
|
// Elimination Finals: Single elimination
|
||||||
|
// EF1: 5th vs 8th
|
||||||
|
// EF2: 6th vs 7th
|
||||||
|
//
|
||||||
|
// Semi Finals (same-side: QF loser faces same-side EF winner):
|
||||||
|
// SF1: Loser(QF1) vs Winner(EF1) — loser eliminated
|
||||||
|
// SF2: Loser(QF2) vs Winner(EF2) — loser eliminated
|
||||||
|
//
|
||||||
|
// Preliminary Finals (QF winner vs opposite SF winner):
|
||||||
|
// PF1: Winner(QF1) vs Winner(SF2)
|
||||||
|
// PF2: Winner(QF2) vs Winner(SF1)
|
||||||
|
//
|
||||||
|
// Third Place Playoff:
|
||||||
|
// 3rd: Loser(PF1) vs Loser(PF2)
|
||||||
|
//
|
||||||
|
// Grand Final:
|
||||||
|
// GF: Winner(PF1) vs Winner(PF2)
|
||||||
|
func generate10to15Bracket(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
bracket *PlayoffBracket,
|
||||||
|
leaderboard []*LeaderboardEntry,
|
||||||
|
roundFormats map[string]int,
|
||||||
|
) error {
|
||||||
|
seed1 := leaderboard[0]
|
||||||
|
seed2 := leaderboard[1]
|
||||||
|
seed3 := leaderboard[2]
|
||||||
|
seed4 := leaderboard[3]
|
||||||
|
seed5 := leaderboard[4]
|
||||||
|
seed6 := leaderboard[5]
|
||||||
|
seed7 := leaderboard[6]
|
||||||
|
seed8 := leaderboard[7]
|
||||||
|
|
||||||
|
// Qualifying Finals
|
||||||
|
qf1, err := NewPlayoffSeries(ctx, tx, bracket, 1,
|
||||||
|
"qualifying_final", "QF1",
|
||||||
|
&seed1.Team.ID, &seed4.Team.ID,
|
||||||
|
intPtr(1), intPtr(4),
|
||||||
|
getMatchesToWin(roundFormats, "qualifying_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create QF1")
|
||||||
|
}
|
||||||
|
|
||||||
|
qf2, err := NewPlayoffSeries(ctx, tx, bracket, 2,
|
||||||
|
"qualifying_final", "QF2",
|
||||||
|
&seed2.Team.ID, &seed3.Team.ID,
|
||||||
|
intPtr(2), intPtr(3),
|
||||||
|
getMatchesToWin(roundFormats, "qualifying_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create QF2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elimination Finals
|
||||||
|
qf3, err := NewPlayoffSeries(ctx, tx, bracket, 3,
|
||||||
|
"elimination_final", "EF1",
|
||||||
|
&seed5.Team.ID, &seed8.Team.ID,
|
||||||
|
intPtr(5), intPtr(8),
|
||||||
|
getMatchesToWin(roundFormats, "elimination_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create EF1")
|
||||||
|
}
|
||||||
|
|
||||||
|
qf4, err := NewPlayoffSeries(ctx, tx, bracket, 4,
|
||||||
|
"elimination_final", "EF2",
|
||||||
|
&seed6.Team.ID, &seed7.Team.ID,
|
||||||
|
intPtr(6), intPtr(7),
|
||||||
|
getMatchesToWin(roundFormats, "elimination_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create EF2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semi Finals
|
||||||
|
sf1, err := NewPlayoffSeries(ctx, tx, bracket, 5,
|
||||||
|
"semi_final", "SF1",
|
||||||
|
nil, nil, // Loser(QF1) vs Winner(EF1)
|
||||||
|
nil, nil,
|
||||||
|
getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create SF1")
|
||||||
|
}
|
||||||
|
|
||||||
|
sf2, err := NewPlayoffSeries(ctx, tx, bracket, 6,
|
||||||
|
"semi_final", "SF2",
|
||||||
|
nil, nil, // Loser(QF2) vs Winner(EF2)
|
||||||
|
nil, nil,
|
||||||
|
getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create SF2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preliminary Finals
|
||||||
|
pf1, err := NewPlayoffSeries(ctx, tx, bracket, 7,
|
||||||
|
"preliminary_final", "PF1",
|
||||||
|
nil, nil, // Winner(QF1) vs Winner(SF2)
|
||||||
|
nil, nil,
|
||||||
|
getMatchesToWin(roundFormats, "preliminary_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create PF1")
|
||||||
|
}
|
||||||
|
|
||||||
|
pf2, err := NewPlayoffSeries(ctx, tx, bracket, 8,
|
||||||
|
"preliminary_final", "PF2",
|
||||||
|
nil, nil, // Winner(QF2) vs Winner(SF1)
|
||||||
|
nil, nil,
|
||||||
|
getMatchesToWin(roundFormats, "preliminary_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create PF2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third Place Playoff - Loser(PF1) vs Loser(PF2)
|
||||||
|
tp, err := NewPlayoffSeries(ctx, tx, bracket, 9,
|
||||||
|
"third_place", "Third Place Playoff",
|
||||||
|
nil, nil,
|
||||||
|
nil, nil,
|
||||||
|
getMatchesToWin(roundFormats, "third_place"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create 3rd Place")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grand Final
|
||||||
|
gf, err := NewPlayoffSeries(ctx, tx, bracket, 10,
|
||||||
|
"grand_final", "Grand Final",
|
||||||
|
nil, nil,
|
||||||
|
nil, nil,
|
||||||
|
getMatchesToWin(roundFormats, "grand_final"), SeriesStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create GF")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up advancement
|
||||||
|
// QF1: Winner -> PF1 (team1), Loser -> SF1 (team1)
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, qf1.ID,
|
||||||
|
&pf1.ID, strPtr("team1"), &sf1.ID, strPtr("team1"))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire QF1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// QF2: Winner -> PF2 (team1), Loser -> SF2 (team1)
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, qf2.ID,
|
||||||
|
&pf2.ID, strPtr("team1"), &sf2.ID, strPtr("team1"))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire QF2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EF1 (QF3): Winner -> SF1 (team2), Loser eliminated
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, qf3.ID,
|
||||||
|
&sf1.ID, strPtr("team2"), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire EF1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EF2 (QF4): Winner -> SF2 (team2), Loser eliminated
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, qf4.ID,
|
||||||
|
&sf2.ID, strPtr("team2"), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire EF2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SF1: Winner -> PF2 (team2), Loser eliminated (crosses to face QF2 winner)
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, sf1.ID,
|
||||||
|
&pf2.ID, strPtr("team2"), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire SF1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SF2: Winner -> PF1 (team2), Loser eliminated (crosses to face QF1 winner)
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, sf2.ID,
|
||||||
|
&pf1.ID, strPtr("team2"), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire SF2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PF1: Winner -> GF (team1), Loser -> 3rd Place (team1)
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, pf1.ID,
|
||||||
|
&gf.ID, strPtr("team1"), &tp.ID, strPtr("team1"))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire PF1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PF2: Winner -> GF (team2), Loser -> 3rd Place (team2)
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, pf2.ID,
|
||||||
|
&gf.ID, strPtr("team2"), &tp.ID, strPtr("team2"))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire PF2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3rd Place and Grand Final are terminal — no advancement
|
||||||
|
_ = tp
|
||||||
|
_ = gf
|
||||||
|
return nil
|
||||||
|
}
|
||||||
355
internal/db/playoff_results.go
Normal file
355
internal/db/playoff_results.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// playoffFixtureRound generates a unique negative round number for a playoff game fixture.
|
||||||
|
// Format: -(seriesID * 100 + matchNumber) to avoid collision with regular season rounds.
|
||||||
|
func playoffFixtureRound(seriesID, matchNumber int) int {
|
||||||
|
return -(seriesID*100 + matchNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePlayoffGameFixture creates a Fixture record for a playoff game.
|
||||||
|
// The fixture is linked to the series via a PlayoffMatch record.
|
||||||
|
// team1 is "home", team2 is "away" in fixture terms.
|
||||||
|
func CreatePlayoffGameFixture(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
series *PlayoffSeries,
|
||||||
|
matchNumber int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) (*Fixture, *PlayoffMatch, error) {
|
||||||
|
if series == nil || series.Bracket == nil {
|
||||||
|
return nil, nil, errors.New("series and bracket cannot be nil")
|
||||||
|
}
|
||||||
|
if series.Team1ID == nil || series.Team2ID == nil {
|
||||||
|
return nil, nil, BadRequest("both teams must be assigned to create a game fixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
round := playoffFixtureRound(series.ID, matchNumber)
|
||||||
|
|
||||||
|
fixture := &Fixture{
|
||||||
|
SeasonID: series.Bracket.SeasonID,
|
||||||
|
LeagueID: series.Bracket.LeagueID,
|
||||||
|
HomeTeamID: *series.Team1ID,
|
||||||
|
AwayTeamID: *series.Team2ID,
|
||||||
|
Round: round,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Insert(tx, fixture).WithAudit(audit, &AuditInfo{
|
||||||
|
Action: "playoff_fixture.create",
|
||||||
|
ResourceType: "fixture",
|
||||||
|
ResourceID: nil,
|
||||||
|
Details: map[string]any{
|
||||||
|
"series_id": series.ID,
|
||||||
|
"match_number": matchNumber,
|
||||||
|
"home_team_id": *series.Team1ID,
|
||||||
|
"away_team_id": *series.Team2ID,
|
||||||
|
},
|
||||||
|
}).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "Insert fixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update PlayoffMatch record
|
||||||
|
match := new(PlayoffMatch)
|
||||||
|
err = tx.NewSelect().
|
||||||
|
Model(match).
|
||||||
|
Where("pm.series_id = ?", series.ID).
|
||||||
|
Where("pm.match_number = ?", matchNumber).
|
||||||
|
Scan(ctx)
|
||||||
|
|
||||||
|
if err != nil && err.Error() != "sql: no rows in result set" {
|
||||||
|
return nil, nil, errors.Wrap(err, "tx.NewSelect playoff_match")
|
||||||
|
}
|
||||||
|
|
||||||
|
if match.ID > 0 {
|
||||||
|
// Update existing match with fixture ID
|
||||||
|
match.FixtureID = &fixture.ID
|
||||||
|
match.HomeTeamID = series.Team1ID
|
||||||
|
match.AwayTeamID = series.Team2ID
|
||||||
|
match.Status = "pending"
|
||||||
|
err = UpdateByID(tx, match.ID, match).
|
||||||
|
Column("fixture_id", "home_team_id", "away_team_id", "status").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "UpdateByID playoff_match")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new match
|
||||||
|
match = &PlayoffMatch{
|
||||||
|
SeriesID: series.ID,
|
||||||
|
MatchNumber: matchNumber,
|
||||||
|
HomeTeamID: series.Team1ID,
|
||||||
|
AwayTeamID: series.Team2ID,
|
||||||
|
FixtureID: &fixture.ID,
|
||||||
|
Status: "pending",
|
||||||
|
}
|
||||||
|
err = Insert(tx, match).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "Insert playoff_match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load fixture relations
|
||||||
|
fixture.Season = series.Bracket.Season
|
||||||
|
fixture.League = series.Bracket.League
|
||||||
|
fixture.HomeTeam = series.Team1
|
||||||
|
fixture.AwayTeam = series.Team2
|
||||||
|
|
||||||
|
return fixture, match, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinalizeSeriesResults finalizes all pending game results for a series,
|
||||||
|
// updates series wins/status, and advances teams as needed.
|
||||||
|
// Returns the number of games finalized.
|
||||||
|
func FinalizeSeriesResults(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seriesID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) (int, error) {
|
||||||
|
series, err := GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
return 0, BadRequest("series not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if series.Status == SeriesStatusCompleted {
|
||||||
|
return 0, BadRequest("series is already completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all matches with fixtures that have pending results
|
||||||
|
gamesFinalized := 0
|
||||||
|
team1Wins := 0
|
||||||
|
team2Wins := 0
|
||||||
|
|
||||||
|
for _, match := range series.Matches {
|
||||||
|
if match.FixtureID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := GetFixtureResult(ctx, tx, *match.FixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "GetFixtureResult")
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize the fixture result if pending
|
||||||
|
if !result.Finalized {
|
||||||
|
err = FinalizeFixtureResult(ctx, tx, *match.FixtureID, audit)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "FinalizeFixtureResult")
|
||||||
|
}
|
||||||
|
gamesFinalized++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update match status
|
||||||
|
now := time.Now().Unix()
|
||||||
|
match.Status = "completed"
|
||||||
|
err = UpdateByID(tx, match.ID, match).
|
||||||
|
Column("status").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "UpdateByID playoff_match")
|
||||||
|
}
|
||||||
|
_ = now
|
||||||
|
|
||||||
|
// Count wins: team1 = home, team2 = away in fixture terms
|
||||||
|
if result.Winner == "home" {
|
||||||
|
team1Wins++
|
||||||
|
} else {
|
||||||
|
team2Wins++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if gamesFinalized == 0 {
|
||||||
|
return 0, BadRequest("no pending results to finalize")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update series wins
|
||||||
|
series.Team1Wins = team1Wins
|
||||||
|
series.Team2Wins = team2Wins
|
||||||
|
|
||||||
|
// Determine if series is decided
|
||||||
|
if team1Wins >= series.MatchesToWin || team2Wins >= series.MatchesToWin {
|
||||||
|
series.Status = SeriesStatusCompleted
|
||||||
|
|
||||||
|
if team1Wins >= series.MatchesToWin {
|
||||||
|
series.WinnerTeamID = series.Team1ID
|
||||||
|
series.LoserTeamID = series.Team2ID
|
||||||
|
} else {
|
||||||
|
series.WinnerTeamID = series.Team2ID
|
||||||
|
series.LoserTeamID = series.Team1ID
|
||||||
|
}
|
||||||
|
|
||||||
|
err = UpdateByID(tx, series.ID, series).
|
||||||
|
Column("team1_wins", "team2_wins", "status", "winner_team_id", "loser_team_id").
|
||||||
|
WithAudit(audit, &AuditInfo{
|
||||||
|
Action: "playoff_series.complete",
|
||||||
|
ResourceType: "playoff_series",
|
||||||
|
ResourceID: series.ID,
|
||||||
|
Details: map[string]any{
|
||||||
|
"team1_wins": team1Wins,
|
||||||
|
"team2_wins": team2Wins,
|
||||||
|
"winner_team_id": series.WinnerTeamID,
|
||||||
|
"loser_team_id": series.LoserTeamID,
|
||||||
|
},
|
||||||
|
}).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "UpdateByID series complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance winner to next series
|
||||||
|
if series.WinnerNextID != nil && series.WinnerNextSlot != nil {
|
||||||
|
err = advanceTeamToSeries(ctx, tx, *series.WinnerNextID, *series.WinnerNextSlot, *series.WinnerTeamID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "advanceTeamToSeries winner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance loser to next series (e.g. third place, lower bracket)
|
||||||
|
if series.LoserNextID != nil && series.LoserNextSlot != nil {
|
||||||
|
err = advanceTeamToSeries(ctx, tx, *series.LoserNextID, *series.LoserNextSlot, *series.LoserTeamID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "advanceTeamToSeries loser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Series still in progress
|
||||||
|
series.Status = SeriesStatusInProgress
|
||||||
|
err = UpdateByID(tx, series.ID, series).
|
||||||
|
Column("team1_wins", "team2_wins", "status").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "UpdateByID series in_progress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return gamesFinalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceTeamToSeries places a team into the specified slot of the target series.
|
||||||
|
func advanceTeamToSeries(ctx context.Context, tx bun.Tx, targetSeriesID int, slot string, teamID int) error {
|
||||||
|
switch slot {
|
||||||
|
case "team1":
|
||||||
|
_, err := tx.NewUpdate().
|
||||||
|
Model((*PlayoffSeries)(nil)).
|
||||||
|
Set("team1_id = ?", teamID).
|
||||||
|
Where("id = ?", targetSeriesID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "update team1_id")
|
||||||
|
}
|
||||||
|
case "team2":
|
||||||
|
_, err := tx.NewUpdate().
|
||||||
|
Model((*PlayoffSeries)(nil)).
|
||||||
|
Set("team2_id = ?", teamID).
|
||||||
|
Where("id = ?", targetSeriesID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "update team2_id")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return BadRequest("invalid slot: " + slot)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSeriesResults deletes all pending (non-finalized) fixture results
|
||||||
|
// and their associated fixtures for a series.
|
||||||
|
func DeleteSeriesResults(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seriesID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
series, err := GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
return BadRequest("series not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, match := range series.Matches {
|
||||||
|
if match.FixtureID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := GetFixtureResult(ctx, tx, *match.FixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetFixtureResult")
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.Finalized {
|
||||||
|
return BadRequest("cannot discard finalized results")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the result (CASCADE deletes player stats)
|
||||||
|
err = DeleteFixtureResult(ctx, tx, *match.FixtureID, audit)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "DeleteFixtureResult")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the fixture
|
||||||
|
err = DeleteByID[Fixture](tx, *match.FixtureID).
|
||||||
|
WithAudit(audit, &AuditInfo{
|
||||||
|
Action: "playoff_fixture.delete",
|
||||||
|
ResourceType: "fixture",
|
||||||
|
ResourceID: *match.FixtureID,
|
||||||
|
}).Delete(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "DeleteByID fixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear fixture ID from match
|
||||||
|
match.FixtureID = nil
|
||||||
|
match.Status = "pending"
|
||||||
|
err = UpdateByID(tx, match.ID, match).
|
||||||
|
Column("fixture_id", "status").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "UpdateByID playoff_match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPendingSeriesResults checks if a series has any pending (non-finalized) results.
|
||||||
|
func HasPendingSeriesResults(ctx context.Context, tx bun.Tx, seriesID int) (bool, error) {
|
||||||
|
series, err := GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, match := range series.Matches {
|
||||||
|
if match.FixtureID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := GetPendingFixtureResult(ctx, tx, *match.FixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "GetPendingFixtureResult")
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
370
internal/db/playoff_schedule.go
Normal file
370
internal/db/playoff_schedule.go
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlayoffSeriesSchedule represents a schedule proposal for a playoff series.
|
||||||
|
// Mirrors FixtureSchedule but references a series instead of a fixture.
|
||||||
|
type PlayoffSeriesSchedule struct {
|
||||||
|
bun.BaseModel `bun:"table:playoff_series_schedules,alias:pss"`
|
||||||
|
|
||||||
|
ID int `bun:"id,pk,autoincrement"`
|
||||||
|
SeriesID int `bun:",notnull"`
|
||||||
|
ScheduledTime *time.Time `bun:"scheduled_time"`
|
||||||
|
ProposedByTeamID int `bun:",notnull"`
|
||||||
|
AcceptedByTeamID *int `bun:"accepted_by_team_id"`
|
||||||
|
Status ScheduleStatus `bun:",notnull,default:'pending'"`
|
||||||
|
RescheduleReason *string `bun:"reschedule_reason"`
|
||||||
|
CreatedAt int64 `bun:",notnull"`
|
||||||
|
UpdatedAt *int64 `bun:"updated_at"`
|
||||||
|
|
||||||
|
Series *PlayoffSeries `bun:"rel:belongs-to,join:series_id=id"`
|
||||||
|
ProposedBy *Team `bun:"rel:belongs-to,join:proposed_by_team_id=id"`
|
||||||
|
AcceptedBy *Team `bun:"rel:belongs-to,join:accepted_by_team_id=id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentSeriesSchedule returns the most recent schedule record for a series.
|
||||||
|
// Returns nil, nil if no schedule exists.
|
||||||
|
func GetCurrentSeriesSchedule(ctx context.Context, tx bun.Tx, seriesID int) (*PlayoffSeriesSchedule, error) {
|
||||||
|
schedule := new(PlayoffSeriesSchedule)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(schedule).
|
||||||
|
Where("series_id = ?", seriesID).
|
||||||
|
Order("created_at DESC", "id DESC").
|
||||||
|
Relation("ProposedBy").
|
||||||
|
Relation("AcceptedBy").
|
||||||
|
Limit(1).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "sql: no rows in result set" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return schedule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSeriesScheduleHistory returns all schedule records for a series in chronological order
|
||||||
|
func GetSeriesScheduleHistory(ctx context.Context, tx bun.Tx, seriesID int) ([]*PlayoffSeriesSchedule, error) {
|
||||||
|
schedules, err := GetList[PlayoffSeriesSchedule](tx).
|
||||||
|
Where("series_id = ?", seriesID).
|
||||||
|
Order("created_at ASC", "id ASC").
|
||||||
|
Relation("ProposedBy").
|
||||||
|
Relation("AcceptedBy").
|
||||||
|
GetAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetList")
|
||||||
|
}
|
||||||
|
return schedules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProposeSeriesSchedule creates a new pending schedule proposal for a series.
|
||||||
|
// Cannot propose on cancelled or accepted schedules.
|
||||||
|
func ProposeSeriesSchedule(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seriesID, proposedByTeamID int,
|
||||||
|
scheduledTime time.Time,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) (*PlayoffSeriesSchedule, error) {
|
||||||
|
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetCurrentSeriesSchedule")
|
||||||
|
}
|
||||||
|
if current != nil {
|
||||||
|
switch current.Status {
|
||||||
|
case ScheduleStatusCancelled:
|
||||||
|
return nil, BadRequest("cannot propose a new time for a cancelled series")
|
||||||
|
case ScheduleStatusAccepted:
|
||||||
|
return nil, BadRequest("series already has an accepted schedule; use reschedule instead")
|
||||||
|
case ScheduleStatusPending:
|
||||||
|
// Supersede existing pending record
|
||||||
|
now := time.Now().Unix()
|
||||||
|
current.Status = ScheduleStatusRescheduled
|
||||||
|
current.UpdatedAt = &now
|
||||||
|
err = UpdateByID(tx, current.ID, current).
|
||||||
|
Column("status", "updated_at").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "UpdateByID")
|
||||||
|
}
|
||||||
|
// rejected, rescheduled, postponed, withdrawn are terminal — safe to create a new proposal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule := &PlayoffSeriesSchedule{
|
||||||
|
SeriesID: seriesID,
|
||||||
|
ScheduledTime: &scheduledTime,
|
||||||
|
ProposedByTeamID: proposedByTeamID,
|
||||||
|
Status: ScheduleStatusPending,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
|
||||||
|
Action: "series_schedule.propose",
|
||||||
|
ResourceType: "playoff_series_schedule",
|
||||||
|
ResourceID: seriesID,
|
||||||
|
Details: map[string]any{
|
||||||
|
"series_id": seriesID,
|
||||||
|
"proposed_by": proposedByTeamID,
|
||||||
|
"scheduled_time": scheduledTime,
|
||||||
|
},
|
||||||
|
}).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "Insert")
|
||||||
|
}
|
||||||
|
return schedule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptSeriesSchedule accepts a pending schedule proposal.
|
||||||
|
// The acceptedByTeamID must be the other team (not the proposer).
|
||||||
|
func AcceptSeriesSchedule(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
scheduleID, acceptedByTeamID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetByID")
|
||||||
|
}
|
||||||
|
if schedule.Status != ScheduleStatusPending {
|
||||||
|
return BadRequest("schedule is not in pending status")
|
||||||
|
}
|
||||||
|
if schedule.ProposedByTeamID == acceptedByTeamID {
|
||||||
|
return BadRequest("cannot accept your own proposal")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
schedule.AcceptedByTeamID = &acceptedByTeamID
|
||||||
|
schedule.Status = ScheduleStatusAccepted
|
||||||
|
schedule.UpdatedAt = &now
|
||||||
|
err = UpdateByID(tx, schedule.ID, schedule).
|
||||||
|
Column("accepted_by_team_id", "status", "updated_at").
|
||||||
|
WithAudit(audit, &AuditInfo{
|
||||||
|
Action: "series_schedule.accept",
|
||||||
|
ResourceType: "playoff_series_schedule",
|
||||||
|
ResourceID: scheduleID,
|
||||||
|
Details: map[string]any{
|
||||||
|
"series_id": schedule.SeriesID,
|
||||||
|
"accepted_by": acceptedByTeamID,
|
||||||
|
},
|
||||||
|
}).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "UpdateByID")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectSeriesSchedule rejects a pending schedule proposal.
|
||||||
|
func RejectSeriesSchedule(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
scheduleID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetByID")
|
||||||
|
}
|
||||||
|
if schedule.Status != ScheduleStatusPending {
|
||||||
|
return BadRequest("schedule is not in pending status")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
schedule.Status = ScheduleStatusRejected
|
||||||
|
schedule.UpdatedAt = &now
|
||||||
|
err = UpdateByID(tx, schedule.ID, schedule).
|
||||||
|
Column("status", "updated_at").
|
||||||
|
WithAudit(audit, &AuditInfo{
|
||||||
|
Action: "series_schedule.reject",
|
||||||
|
ResourceType: "playoff_series_schedule",
|
||||||
|
ResourceID: scheduleID,
|
||||||
|
Details: map[string]any{
|
||||||
|
"series_id": schedule.SeriesID,
|
||||||
|
},
|
||||||
|
}).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "UpdateByID")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RescheduleSeriesSchedule marks the current accepted schedule as rescheduled
|
||||||
|
// and creates a new pending proposal with the new time.
|
||||||
|
func RescheduleSeriesSchedule(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seriesID, proposedByTeamID int,
|
||||||
|
newTime time.Time,
|
||||||
|
reason string,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) (*PlayoffSeriesSchedule, error) {
|
||||||
|
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetCurrentSeriesSchedule")
|
||||||
|
}
|
||||||
|
if current == nil || current.Status != ScheduleStatusAccepted {
|
||||||
|
return nil, BadRequest("no accepted schedule to reschedule")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
current.Status = ScheduleStatusRescheduled
|
||||||
|
current.RescheduleReason = &reason
|
||||||
|
current.UpdatedAt = &now
|
||||||
|
err = UpdateByID(tx, current.ID, current).
|
||||||
|
Column("status", "reschedule_reason", "updated_at").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "UpdateByID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new pending proposal
|
||||||
|
schedule := &PlayoffSeriesSchedule{
|
||||||
|
SeriesID: seriesID,
|
||||||
|
ScheduledTime: &newTime,
|
||||||
|
ProposedByTeamID: proposedByTeamID,
|
||||||
|
Status: ScheduleStatusPending,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
|
||||||
|
Action: "series_schedule.reschedule",
|
||||||
|
ResourceType: "playoff_series_schedule",
|
||||||
|
ResourceID: seriesID,
|
||||||
|
Details: map[string]any{
|
||||||
|
"series_id": seriesID,
|
||||||
|
"proposed_by": proposedByTeamID,
|
||||||
|
"new_time": newTime,
|
||||||
|
"reason": reason,
|
||||||
|
"old_schedule_id": current.ID,
|
||||||
|
},
|
||||||
|
}).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "Insert")
|
||||||
|
}
|
||||||
|
return schedule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostponeSeriesSchedule marks the current accepted schedule as postponed.
|
||||||
|
// This is a terminal state — a new proposal can be created afterwards.
|
||||||
|
func PostponeSeriesSchedule(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seriesID int,
|
||||||
|
reason string,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetCurrentSeriesSchedule")
|
||||||
|
}
|
||||||
|
if current == nil || current.Status != ScheduleStatusAccepted {
|
||||||
|
return BadRequest("no accepted schedule to postpone")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
current.Status = ScheduleStatusPostponed
|
||||||
|
current.RescheduleReason = &reason
|
||||||
|
current.UpdatedAt = &now
|
||||||
|
err = UpdateByID(tx, current.ID, current).
|
||||||
|
Column("status", "reschedule_reason", "updated_at").
|
||||||
|
WithAudit(audit, &AuditInfo{
|
||||||
|
Action: "series_schedule.postpone",
|
||||||
|
ResourceType: "playoff_series_schedule",
|
||||||
|
ResourceID: seriesID,
|
||||||
|
Details: map[string]any{
|
||||||
|
"series_id": seriesID,
|
||||||
|
"reason": reason,
|
||||||
|
"old_schedule_id": current.ID,
|
||||||
|
},
|
||||||
|
}).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "UpdateByID")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithdrawSeriesSchedule allows the proposer to withdraw their pending proposal.
|
||||||
|
// Only the team that proposed can withdraw it.
|
||||||
|
func WithdrawSeriesSchedule(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
scheduleID, withdrawByTeamID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetByID")
|
||||||
|
}
|
||||||
|
if schedule.Status != ScheduleStatusPending {
|
||||||
|
return BadRequest("schedule is not in pending status")
|
||||||
|
}
|
||||||
|
if schedule.ProposedByTeamID != withdrawByTeamID {
|
||||||
|
return BadRequest("only the proposing team can withdraw their proposal")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
schedule.Status = ScheduleStatusWithdrawn
|
||||||
|
schedule.UpdatedAt = &now
|
||||||
|
err = UpdateByID(tx, schedule.ID, schedule).
|
||||||
|
Column("status", "updated_at").
|
||||||
|
WithAudit(audit, &AuditInfo{
|
||||||
|
Action: "series_schedule.withdraw",
|
||||||
|
ResourceType: "playoff_series_schedule",
|
||||||
|
ResourceID: scheduleID,
|
||||||
|
Details: map[string]any{
|
||||||
|
"series_id": schedule.SeriesID,
|
||||||
|
"withdrawn_by": withdrawByTeamID,
|
||||||
|
},
|
||||||
|
}).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "UpdateByID")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelSeriesSchedule marks the current schedule as cancelled. This is a terminal state.
|
||||||
|
// Requires playoffs.manage permission (moderator-level).
|
||||||
|
func CancelSeriesSchedule(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seriesID int,
|
||||||
|
reason string,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetCurrentSeriesSchedule")
|
||||||
|
}
|
||||||
|
if current == nil {
|
||||||
|
return BadRequest("no schedule to cancel")
|
||||||
|
}
|
||||||
|
if current.Status.IsTerminal() {
|
||||||
|
return BadRequest("schedule is already in a terminal state")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
current.Status = ScheduleStatusCancelled
|
||||||
|
current.RescheduleReason = &reason
|
||||||
|
current.UpdatedAt = &now
|
||||||
|
err = UpdateByID(tx, current.ID, current).
|
||||||
|
Column("status", "reschedule_reason", "updated_at").
|
||||||
|
WithAudit(audit, &AuditInfo{
|
||||||
|
Action: "series_schedule.cancel",
|
||||||
|
ResourceType: "playoff_series_schedule",
|
||||||
|
ResourceID: seriesID,
|
||||||
|
Details: map[string]any{
|
||||||
|
"series_id": seriesID,
|
||||||
|
"reason": reason,
|
||||||
|
"schedule_id": current.ID,
|
||||||
|
},
|
||||||
|
}).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "UpdateByID")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
256
internal/db/playoff_stats.go
Normal file
256
internal/db/playoff_stats.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetPlayoffPlayerStats returns aggregated player stats from playoff fixtures only
|
||||||
|
// (fixtures with round < 0) for a season-league.
|
||||||
|
// Reuses the same LeaguePlayerStats struct as regular season stats.
|
||||||
|
func GetPlayoffPlayerStats(
|
||||||
|
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 f.round < 0
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayoffTopGoalScorers returns the top 10 goal scorers from playoff fixtures.
|
||||||
|
func GetPlayoffTopGoalScorers(
|
||||||
|
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 f.round < 0
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayoffTopAssisters returns the top 10 assisters from playoff fixtures.
|
||||||
|
func GetPlayoffTopAssisters(
|
||||||
|
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 f.round < 0
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlayoffTopSavers returns the top 10 savers from playoff fixtures.
|
||||||
|
func GetPlayoffTopSavers(
|
||||||
|
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 f.round < 0
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -142,7 +142,12 @@ type LeagueWithTeams struct {
|
|||||||
Teams []*Team
|
Teams []*Team
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatus returns the current status of the season based on dates
|
// GetStatus returns the current status of the season based on dates.
|
||||||
|
// Dates are treated as inclusive days:
|
||||||
|
// - StartDate: season is "in progress" from the start of this day
|
||||||
|
// - EndDate: season is "in progress" through the end of this day
|
||||||
|
// - FinalsStartDate: finals are active from the start of this day
|
||||||
|
// - FinalsEndDate: finals are active through the end of this day
|
||||||
func (s *Season) GetStatus() SeasonStatus {
|
func (s *Season) GetStatus() SeasonStatus {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
@@ -150,20 +155,32 @@ func (s *Season) GetStatus() SeasonStatus {
|
|||||||
return StatusUpcoming
|
return StatusUpcoming
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dayPassed returns true if the entire calendar day of t has passed.
|
||||||
|
// e.g., if t is March 8, this returns true starting March 9 00:00:00.
|
||||||
|
dayPassed := func(t time.Time) bool {
|
||||||
|
return now.After(t.Truncate(time.Hour*24).AddDate(0, 0, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayStarted returns true if the calendar day of t has started.
|
||||||
|
// e.g., if t is March 8, this returns true starting March 8 00:00:00.
|
||||||
|
dayStarted := func(t time.Time) bool {
|
||||||
|
return !now.Before(t.Truncate(time.Hour * 24))
|
||||||
|
}
|
||||||
|
|
||||||
if !s.FinalsStartDate.IsZero() {
|
if !s.FinalsStartDate.IsZero() {
|
||||||
if !s.FinalsEndDate.IsZero() && now.After(s.FinalsEndDate.Time) {
|
if !s.FinalsEndDate.IsZero() && dayPassed(s.FinalsEndDate.Time) {
|
||||||
return StatusCompleted
|
return StatusCompleted
|
||||||
}
|
}
|
||||||
if now.After(s.FinalsStartDate.Time) {
|
if dayStarted(s.FinalsStartDate.Time) {
|
||||||
return StatusFinals
|
return StatusFinals
|
||||||
}
|
}
|
||||||
if !s.EndDate.IsZero() && now.After(s.EndDate.Time) {
|
if !s.EndDate.IsZero() && dayPassed(s.EndDate.Time) {
|
||||||
return StatusFinalsSoon
|
return StatusFinalsSoon
|
||||||
}
|
}
|
||||||
return StatusInProgress
|
return StatusInProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.EndDate.IsZero() && now.After(s.EndDate.Time) {
|
if !s.EndDate.IsZero() && dayPassed(s.EndDate.Time) {
|
||||||
return StatusCompleted
|
return StatusCompleted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ func (db *DB) RegisterModels() []any {
|
|||||||
(*Player)(nil),
|
(*Player)(nil),
|
||||||
(*FixtureResult)(nil),
|
(*FixtureResult)(nil),
|
||||||
(*FixtureResultPlayerStats)(nil),
|
(*FixtureResultPlayerStats)(nil),
|
||||||
|
(*PlayoffBracket)(nil),
|
||||||
|
(*PlayoffSeries)(nil),
|
||||||
|
(*PlayoffMatch)(nil),
|
||||||
}
|
}
|
||||||
db.RegisterModel(models...)
|
db.RegisterModel(models...)
|
||||||
return models
|
return models
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
@@ -72,3 +73,149 @@ func (t *Team) InSeason(seasonID int) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TeamSeasonInfo holds information about a team's participation in a specific season+league.
|
||||||
|
type TeamSeasonInfo struct {
|
||||||
|
Season *Season
|
||||||
|
League *League
|
||||||
|
Record *TeamRecord
|
||||||
|
TotalTeams int
|
||||||
|
Position int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTeamSeasonParticipation returns all season+league combos the team participated in,
|
||||||
|
// with computed records, positions, and total team counts.
|
||||||
|
func GetTeamSeasonParticipation(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
teamID int,
|
||||||
|
) ([]*TeamSeasonInfo, error) {
|
||||||
|
if teamID == 0 {
|
||||||
|
return nil, errors.New("teamID not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all participations for this team
|
||||||
|
var participations []*TeamParticipation
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&participations).
|
||||||
|
Where("team_id = ?", teamID).
|
||||||
|
Relation("Season", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Relation("Leagues")
|
||||||
|
}).
|
||||||
|
Relation("League").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect participations")
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []*TeamSeasonInfo
|
||||||
|
|
||||||
|
for _, p := range participations {
|
||||||
|
// Get all teams in this season+league for position calculation
|
||||||
|
var teams []*Team
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&teams).
|
||||||
|
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
|
||||||
|
Where("tp.season_id = ? AND tp.league_id = ?", p.SeasonID, p.LeagueID).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect teams")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all fixtures for this season+league
|
||||||
|
fixtures, err := GetAllocatedFixtures(ctx, tx, p.SeasonID, p.LeagueID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetAllocatedFixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
fixtureIDs := make([]int, len(fixtures))
|
||||||
|
for i, f := range fixtures {
|
||||||
|
fixtureIDs[i] = f.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMap, err := GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute leaderboard to get position
|
||||||
|
leaderboard := ComputeLeaderboard(teams, fixtures, resultMap)
|
||||||
|
|
||||||
|
var position int
|
||||||
|
var record *TeamRecord
|
||||||
|
for _, entry := range leaderboard {
|
||||||
|
if entry.Team.ID == teamID {
|
||||||
|
position = entry.Position
|
||||||
|
record = entry.Record
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if record == nil {
|
||||||
|
record = &TeamRecord{}
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, &TeamSeasonInfo{
|
||||||
|
Season: p.Season,
|
||||||
|
League: p.League,
|
||||||
|
Record: record,
|
||||||
|
TotalTeams: len(teams),
|
||||||
|
Position: position,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by season start date descending (newest first)
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return results[i].Season.StartDate.After(results[j].Season.StartDate)
|
||||||
|
})
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeamAllTimePlayerStats holds aggregated all-time stats for a player on a team.
|
||||||
|
type TeamAllTimePlayerStats struct {
|
||||||
|
PlayerID int `bun:"player_id"`
|
||||||
|
PlayerName string `bun:"player_name"`
|
||||||
|
SeasonsPlayed int `bun:"seasons_played"`
|
||||||
|
PeriodsPlayed int `bun:"total_periods_played"`
|
||||||
|
Goals int `bun:"total_goals"`
|
||||||
|
Assists int `bun:"total_assists"`
|
||||||
|
Saves int `bun:"total_saves"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTeamAllTimePlayerStats returns aggregated all-time stats for all players
|
||||||
|
// who have ever played for a given team across all seasons.
|
||||||
|
func GetTeamAllTimePlayerStats(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
teamID int,
|
||||||
|
) ([]*TeamAllTimePlayerStats, error) {
|
||||||
|
if teamID == 0 {
|
||||||
|
return nil, errors.New("teamID not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats []*TeamAllTimePlayerStats
|
||||||
|
err := tx.NewRaw(`
|
||||||
|
SELECT
|
||||||
|
frps.player_id AS player_id,
|
||||||
|
COALESCE(p.name, frps.player_username) AS player_name,
|
||||||
|
COUNT(DISTINCT s.id) AS seasons_played,
|
||||||
|
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
||||||
|
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
||||||
|
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
||||||
|
COALESCE(SUM(frps.saves), 0) AS total_saves
|
||||||
|
FROM fixture_result_player_stats frps
|
||||||
|
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
||||||
|
JOIN fixtures f ON f.id = fr.fixture_id
|
||||||
|
JOIN seasons s ON s.id = f.season_id
|
||||||
|
LEFT JOIN players p ON p.id = frps.player_id
|
||||||
|
WHERE fr.finalized = true
|
||||||
|
AND frps.team_id = ?
|
||||||
|
AND frps.period_num = 3
|
||||||
|
AND frps.player_id IS NOT NULL
|
||||||
|
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
||||||
|
`, teamID).Scan(ctx, &stats)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewRaw")
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -129,72 +129,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Scrollbar Styles - Catppuccin Theme */
|
/* 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-width: thin;
|
||||||
scrollbar-color: var(--surface1) var(--mantle);
|
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Webkit browsers (Chrome, Safari, Edge) */
|
/* Modal content */
|
||||||
::-webkit-scrollbar {
|
.modal-scrollable {
|
||||||
width: 8px;
|
--scrollbar-thumb: var(--surface1);
|
||||||
height: 8px;
|
--scrollbar-track: var(--base);
|
||||||
}
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||||
::-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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
--container-3xl: 48rem;
|
--container-3xl: 48rem;
|
||||||
--container-4xl: 56rem;
|
--container-4xl: 56rem;
|
||||||
--container-5xl: 64rem;
|
--container-5xl: 64rem;
|
||||||
|
--container-6xl: 72rem;
|
||||||
--container-7xl: 80rem;
|
--container-7xl: 80rem;
|
||||||
--text-xs: 0.75rem;
|
--text-xs: 0.75rem;
|
||||||
--text-xs--line-height: calc(1 / 0.75);
|
--text-xs--line-height: calc(1 / 0.75);
|
||||||
@@ -35,6 +36,8 @@
|
|||||||
--text-3xl--line-height: calc(2.25 / 1.875);
|
--text-3xl--line-height: calc(2.25 / 1.875);
|
||||||
--text-4xl: 2.25rem;
|
--text-4xl: 2.25rem;
|
||||||
--text-4xl--line-height: calc(2.5 / 2.25);
|
--text-4xl--line-height: calc(2.5 / 2.25);
|
||||||
|
--text-5xl: 3rem;
|
||||||
|
--text-5xl--line-height: 1;
|
||||||
--text-6xl: 3.75rem;
|
--text-6xl: 3.75rem;
|
||||||
--text-6xl--line-height: 1;
|
--text-6xl--line-height: 1;
|
||||||
--text-9xl: 8rem;
|
--text-9xl: 8rem;
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
--tracking-tight: -0.025em;
|
--tracking-tight: -0.025em;
|
||||||
--tracking-wider: 0.05em;
|
--tracking-wider: 0.05em;
|
||||||
--leading-relaxed: 1.625;
|
--leading-relaxed: 1.625;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
--radius-lg: 0.5rem;
|
--radius-lg: 0.5rem;
|
||||||
--radius-xl: 0.75rem;
|
--radius-xl: 0.75rem;
|
||||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||||
@@ -323,6 +327,9 @@
|
|||||||
max-width: 96rem;
|
max-width: 96rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.mx-1 {
|
||||||
|
margin-inline: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
@@ -338,9 +345,6 @@
|
|||||||
.mt-1 {
|
.mt-1 {
|
||||||
margin-top: calc(var(--spacing) * 1);
|
margin-top: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
.mt-1\.5 {
|
|
||||||
margin-top: calc(var(--spacing) * 1.5);
|
|
||||||
}
|
|
||||||
.mt-2 {
|
.mt-2 {
|
||||||
margin-top: calc(var(--spacing) * 2);
|
margin-top: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -371,9 +375,6 @@
|
|||||||
.mt-24 {
|
.mt-24 {
|
||||||
margin-top: calc(var(--spacing) * 24);
|
margin-top: calc(var(--spacing) * 24);
|
||||||
}
|
}
|
||||||
.mt-25 {
|
|
||||||
margin-top: calc(var(--spacing) * 25);
|
|
||||||
}
|
|
||||||
.mt-auto {
|
.mt-auto {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
@@ -395,6 +396,9 @@
|
|||||||
.mb-8 {
|
.mb-8 {
|
||||||
margin-bottom: calc(var(--spacing) * 8);
|
margin-bottom: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: calc(var(--spacing) * 12);
|
||||||
|
}
|
||||||
.mb-auto {
|
.mb-auto {
|
||||||
margin-bottom: auto;
|
margin-bottom: auto;
|
||||||
}
|
}
|
||||||
@@ -444,12 +448,18 @@
|
|||||||
width: calc(var(--spacing) * 5);
|
width: calc(var(--spacing) * 5);
|
||||||
height: calc(var(--spacing) * 5);
|
height: calc(var(--spacing) * 5);
|
||||||
}
|
}
|
||||||
|
.h-0 {
|
||||||
|
height: calc(var(--spacing) * 0);
|
||||||
|
}
|
||||||
.h-1 {
|
.h-1 {
|
||||||
height: calc(var(--spacing) * 1);
|
height: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
.h-3 {
|
.h-3 {
|
||||||
height: calc(var(--spacing) * 3);
|
height: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
|
.h-3\.5 {
|
||||||
|
height: calc(var(--spacing) * 3.5);
|
||||||
|
}
|
||||||
.h-4 {
|
.h-4 {
|
||||||
height: calc(var(--spacing) * 4);
|
height: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -459,9 +469,18 @@
|
|||||||
.h-6 {
|
.h-6 {
|
||||||
height: calc(var(--spacing) * 6);
|
height: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
.h-9 {
|
||||||
|
height: calc(var(--spacing) * 9);
|
||||||
|
}
|
||||||
|
.h-10 {
|
||||||
|
height: calc(var(--spacing) * 10);
|
||||||
|
}
|
||||||
.h-12 {
|
.h-12 {
|
||||||
height: calc(var(--spacing) * 12);
|
height: calc(var(--spacing) * 12);
|
||||||
}
|
}
|
||||||
|
.h-14 {
|
||||||
|
height: calc(var(--spacing) * 14);
|
||||||
|
}
|
||||||
.h-16 {
|
.h-16 {
|
||||||
height: calc(var(--spacing) * 16);
|
height: calc(var(--spacing) * 16);
|
||||||
}
|
}
|
||||||
@@ -474,6 +493,9 @@
|
|||||||
.h-screen {
|
.h-screen {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
.max-h-40 {
|
||||||
|
max-height: calc(var(--spacing) * 40);
|
||||||
|
}
|
||||||
.max-h-60 {
|
.max-h-60 {
|
||||||
max-height: calc(var(--spacing) * 60);
|
max-height: calc(var(--spacing) * 60);
|
||||||
}
|
}
|
||||||
@@ -510,6 +532,9 @@
|
|||||||
.w-3 {
|
.w-3 {
|
||||||
width: calc(var(--spacing) * 3);
|
width: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
|
.w-3\.5 {
|
||||||
|
width: calc(var(--spacing) * 3.5);
|
||||||
|
}
|
||||||
.w-4 {
|
.w-4 {
|
||||||
width: calc(var(--spacing) * 4);
|
width: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -519,18 +544,36 @@
|
|||||||
.w-6 {
|
.w-6 {
|
||||||
width: calc(var(--spacing) * 6);
|
width: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
.w-8 {
|
||||||
|
width: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
|
.w-9 {
|
||||||
|
width: calc(var(--spacing) * 9);
|
||||||
|
}
|
||||||
.w-10 {
|
.w-10 {
|
||||||
width: calc(var(--spacing) * 10);
|
width: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
.w-12 {
|
.w-12 {
|
||||||
width: calc(var(--spacing) * 12);
|
width: calc(var(--spacing) * 12);
|
||||||
}
|
}
|
||||||
|
.w-14 {
|
||||||
|
width: calc(var(--spacing) * 14);
|
||||||
|
}
|
||||||
|
.w-16 {
|
||||||
|
width: calc(var(--spacing) * 16);
|
||||||
|
}
|
||||||
.w-20 {
|
.w-20 {
|
||||||
width: calc(var(--spacing) * 20);
|
width: calc(var(--spacing) * 20);
|
||||||
}
|
}
|
||||||
|
.w-24 {
|
||||||
|
width: calc(var(--spacing) * 24);
|
||||||
|
}
|
||||||
.w-26 {
|
.w-26 {
|
||||||
width: calc(var(--spacing) * 26);
|
width: calc(var(--spacing) * 26);
|
||||||
}
|
}
|
||||||
|
.w-28 {
|
||||||
|
width: calc(var(--spacing) * 28);
|
||||||
|
}
|
||||||
.w-48 {
|
.w-48 {
|
||||||
width: calc(var(--spacing) * 48);
|
width: calc(var(--spacing) * 48);
|
||||||
}
|
}
|
||||||
@@ -543,9 +586,6 @@
|
|||||||
.w-80 {
|
.w-80 {
|
||||||
width: calc(var(--spacing) * 80);
|
width: calc(var(--spacing) * 80);
|
||||||
}
|
}
|
||||||
.w-fit {
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
.w-full {
|
.w-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -561,6 +601,9 @@
|
|||||||
.max-w-5xl {
|
.max-w-5xl {
|
||||||
max-width: var(--container-5xl);
|
max-width: var(--container-5xl);
|
||||||
}
|
}
|
||||||
|
.max-w-6xl {
|
||||||
|
max-width: var(--container-6xl);
|
||||||
|
}
|
||||||
.max-w-7xl {
|
.max-w-7xl {
|
||||||
max-width: var(--container-7xl);
|
max-width: var(--container-7xl);
|
||||||
}
|
}
|
||||||
@@ -597,6 +640,12 @@
|
|||||||
.min-w-0 {
|
.min-w-0 {
|
||||||
min-width: calc(var(--spacing) * 0);
|
min-width: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
|
.min-w-\[500px\] {
|
||||||
|
min-width: 500px;
|
||||||
|
}
|
||||||
|
.min-w-\[700px\] {
|
||||||
|
min-width: 700px;
|
||||||
|
}
|
||||||
.flex-1 {
|
.flex-1 {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -630,12 +679,18 @@
|
|||||||
--tw-scale-z: 100%;
|
--tw-scale-z: 100%;
|
||||||
scale: var(--tw-scale-x) var(--tw-scale-y);
|
scale: var(--tw-scale-x) var(--tw-scale-y);
|
||||||
}
|
}
|
||||||
|
.rotate-180 {
|
||||||
|
rotate: 180deg;
|
||||||
|
}
|
||||||
.transform {
|
.transform {
|
||||||
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
||||||
}
|
}
|
||||||
.animate-spin {
|
.animate-spin {
|
||||||
animation: var(--animate-spin);
|
animation: var(--animate-spin);
|
||||||
}
|
}
|
||||||
|
.cursor-default {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
.cursor-grab {
|
.cursor-grab {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
@@ -672,6 +727,9 @@
|
|||||||
.grid-cols-3 {
|
.grid-cols-3 {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
.grid-cols-4 {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
.grid-cols-7 {
|
.grid-cols-7 {
|
||||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -684,6 +742,9 @@
|
|||||||
.place-content-center {
|
.place-content-center {
|
||||||
place-content: center;
|
place-content: center;
|
||||||
}
|
}
|
||||||
|
.items-baseline {
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
.items-center {
|
.items-center {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -693,6 +754,9 @@
|
|||||||
.items-start {
|
.items-start {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
.items-stretch {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
.justify-between {
|
.justify-between {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
@@ -702,6 +766,12 @@
|
|||||||
.justify-end {
|
.justify-end {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
.gap-0 {
|
||||||
|
gap: calc(var(--spacing) * 0);
|
||||||
|
}
|
||||||
|
.gap-0\.5 {
|
||||||
|
gap: calc(var(--spacing) * 0.5);
|
||||||
|
}
|
||||||
.gap-1 {
|
.gap-1 {
|
||||||
gap: calc(var(--spacing) * 1);
|
gap: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
@@ -723,6 +793,13 @@
|
|||||||
.gap-8 {
|
.gap-8 {
|
||||||
gap: calc(var(--spacing) * 8);
|
gap: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
|
.space-y-0 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-block-start: calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));
|
||||||
|
margin-block-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
.space-y-0\.5 {
|
.space-y-0\.5 {
|
||||||
:where(& > :not(:last-child)) {
|
:where(& > :not(:last-child)) {
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
@@ -737,6 +814,13 @@
|
|||||||
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
|
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.space-y-1\.5 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-block-start: calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));
|
||||||
|
margin-block-end: calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
.space-y-2 {
|
.space-y-2 {
|
||||||
:where(& > :not(:last-child)) {
|
:where(& > :not(:last-child)) {
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
@@ -765,6 +849,13 @@
|
|||||||
margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
|
margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.space-y-8 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));
|
||||||
|
margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
.gap-x-2 {
|
.gap-x-2 {
|
||||||
column-gap: calc(var(--spacing) * 2);
|
column-gap: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -854,6 +945,9 @@
|
|||||||
.rounded-lg {
|
.rounded-lg {
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
.rounded-md {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
.rounded-xl {
|
.rounded-xl {
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
}
|
}
|
||||||
@@ -873,6 +967,10 @@
|
|||||||
border-top-style: var(--tw-border-style);
|
border-top-style: var(--tw-border-style);
|
||||||
border-top-width: 1px;
|
border-top-width: 1px;
|
||||||
}
|
}
|
||||||
|
.border-t-2 {
|
||||||
|
border-top-style: var(--tw-border-style);
|
||||||
|
border-top-width: 2px;
|
||||||
|
}
|
||||||
.border-b {
|
.border-b {
|
||||||
border-bottom-style: var(--tw-border-style);
|
border-bottom-style: var(--tw-border-style);
|
||||||
border-bottom-width: 1px;
|
border-bottom-width: 1px;
|
||||||
@@ -906,6 +1004,12 @@
|
|||||||
.border-overlay0 {
|
.border-overlay0 {
|
||||||
border-color: var(--overlay0);
|
border-color: var(--overlay0);
|
||||||
}
|
}
|
||||||
|
.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-red {
|
||||||
border-color: var(--red);
|
border-color: var(--red);
|
||||||
}
|
}
|
||||||
@@ -915,6 +1019,12 @@
|
|||||||
border-color: color-mix(in oklab, var(--red) 30%, transparent);
|
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-surface0 {
|
||||||
border-color: var(--surface0);
|
border-color: var(--surface0);
|
||||||
}
|
}
|
||||||
@@ -987,6 +1097,18 @@
|
|||||||
.bg-green {
|
.bg-green {
|
||||||
background-color: var(--green);
|
background-color: var(--green);
|
||||||
}
|
}
|
||||||
|
.bg-green\/5 {
|
||||||
|
background-color: var(--green);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--green) 5%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.bg-green\/10 {
|
||||||
|
background-color: var(--green);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--green) 10%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
.bg-green\/20 {
|
.bg-green\/20 {
|
||||||
background-color: var(--green);
|
background-color: var(--green);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -1005,9 +1127,33 @@
|
|||||||
.bg-mauve {
|
.bg-mauve {
|
||||||
background-color: var(--mauve);
|
background-color: var(--mauve);
|
||||||
}
|
}
|
||||||
|
.bg-overlay0\/10 {
|
||||||
|
background-color: var(--overlay0);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--overlay0) 10%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.bg-overlay0\/20 {
|
||||||
|
background-color: var(--overlay0);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--overlay0) 20%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
.bg-peach {
|
.bg-peach {
|
||||||
background-color: var(--peach);
|
background-color: var(--peach);
|
||||||
}
|
}
|
||||||
|
.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 {
|
.bg-peach\/20 {
|
||||||
background-color: var(--peach);
|
background-color: var(--peach);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -1017,18 +1163,36 @@
|
|||||||
.bg-red {
|
.bg-red {
|
||||||
background-color: var(--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 {
|
.bg-red\/10 {
|
||||||
background-color: var(--red);
|
background-color: var(--red);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
background-color: color-mix(in oklab, var(--red) 10%, transparent);
|
background-color: color-mix(in oklab, var(--red) 10%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.bg-red\/15 {
|
||||||
|
background-color: var(--red);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--red) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
.bg-red\/20 {
|
.bg-red\/20 {
|
||||||
background-color: var(--red);
|
background-color: var(--red);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
background-color: color-mix(in oklab, var(--red) 20%, transparent);
|
background-color: color-mix(in oklab, var(--red) 20%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.bg-red\/30 {
|
||||||
|
background-color: var(--red);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--red) 30%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
.bg-sapphire {
|
.bg-sapphire {
|
||||||
background-color: var(--sapphire);
|
background-color: var(--sapphire);
|
||||||
}
|
}
|
||||||
@@ -1086,6 +1250,9 @@
|
|||||||
.p-8 {
|
.p-8 {
|
||||||
padding: calc(var(--spacing) * 8);
|
padding: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
|
.px-1 {
|
||||||
|
padding-inline: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.px-1\.5 {
|
.px-1\.5 {
|
||||||
padding-inline: calc(var(--spacing) * 1.5);
|
padding-inline: calc(var(--spacing) * 1.5);
|
||||||
}
|
}
|
||||||
@@ -1119,12 +1286,18 @@
|
|||||||
.py-2 {
|
.py-2 {
|
||||||
padding-block: calc(var(--spacing) * 2);
|
padding-block: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.py-2\.5 {
|
||||||
|
padding-block: calc(var(--spacing) * 2.5);
|
||||||
|
}
|
||||||
.py-3 {
|
.py-3 {
|
||||||
padding-block: calc(var(--spacing) * 3);
|
padding-block: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
.py-4 {
|
.py-4 {
|
||||||
padding-block: calc(var(--spacing) * 4);
|
padding-block: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
|
.py-5 {
|
||||||
|
padding-block: calc(var(--spacing) * 5);
|
||||||
|
}
|
||||||
.py-6 {
|
.py-6 {
|
||||||
padding-block: calc(var(--spacing) * 6);
|
padding-block: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
@@ -1149,6 +1322,9 @@
|
|||||||
.pr-2 {
|
.pr-2 {
|
||||||
padding-right: calc(var(--spacing) * 2);
|
padding-right: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.pr-4 {
|
||||||
|
padding-right: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
.pr-10 {
|
.pr-10 {
|
||||||
padding-right: calc(var(--spacing) * 10);
|
padding-right: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
@@ -1164,6 +1340,9 @@
|
|||||||
.pl-3 {
|
.pl-3 {
|
||||||
padding-left: calc(var(--spacing) * 3);
|
padding-left: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
|
.pl-4 {
|
||||||
|
padding-left: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -1188,6 +1367,10 @@
|
|||||||
font-size: var(--text-4xl);
|
font-size: var(--text-4xl);
|
||||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
||||||
}
|
}
|
||||||
|
.text-5xl {
|
||||||
|
font-size: var(--text-5xl);
|
||||||
|
line-height: var(--tw-leading, var(--text-5xl--line-height));
|
||||||
|
}
|
||||||
.text-9xl {
|
.text-9xl {
|
||||||
font-size: var(--text-9xl);
|
font-size: var(--text-9xl);
|
||||||
line-height: var(--tw-leading, var(--text-9xl--line-height));
|
line-height: var(--tw-leading, var(--text-9xl--line-height));
|
||||||
@@ -1263,6 +1446,9 @@
|
|||||||
.whitespace-pre-wrap {
|
.whitespace-pre-wrap {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
.text-base {
|
||||||
|
color: var(--base);
|
||||||
|
}
|
||||||
.text-blue {
|
.text-blue {
|
||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
@@ -1275,6 +1461,9 @@
|
|||||||
.text-mantle {
|
.text-mantle {
|
||||||
color: var(--mantle);
|
color: var(--mantle);
|
||||||
}
|
}
|
||||||
|
.text-mauve {
|
||||||
|
color: var(--mauve);
|
||||||
|
}
|
||||||
.text-overlay0 {
|
.text-overlay0 {
|
||||||
color: var(--overlay0);
|
color: var(--overlay0);
|
||||||
}
|
}
|
||||||
@@ -1296,6 +1485,9 @@
|
|||||||
color: color-mix(in oklab, var(--red) 80%, transparent);
|
color: color-mix(in oklab, var(--red) 80%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.text-sky {
|
||||||
|
color: var(--sky);
|
||||||
|
}
|
||||||
.text-subtext0 {
|
.text-subtext0 {
|
||||||
color: var(--subtext0);
|
color: var(--subtext0);
|
||||||
}
|
}
|
||||||
@@ -1341,6 +1533,9 @@
|
|||||||
.italic {
|
.italic {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
.underline {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
}
|
||||||
.placeholder-subtext0 {
|
.placeholder-subtext0 {
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: var(--subtext0);
|
color: var(--subtext0);
|
||||||
@@ -1398,6 +1593,11 @@
|
|||||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
}
|
}
|
||||||
|
.transition-transform {
|
||||||
|
transition-property: transform, translate, scale, rotate;
|
||||||
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
|
}
|
||||||
.duration-150 {
|
.duration-150 {
|
||||||
--tw-duration: 150ms;
|
--tw-duration: 150ms;
|
||||||
transition-duration: 150ms;
|
transition-duration: 150ms;
|
||||||
@@ -1433,6 +1633,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.group-hover\:text-blue {
|
||||||
|
&:is(:where(.group):hover *) {
|
||||||
|
@media (hover: hover) {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.group-hover\:opacity-100 {
|
.group-hover\:opacity-100 {
|
||||||
&:is(:where(.group):hover *) {
|
&:is(:where(.group):hover *) {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -1495,6 +1702,12 @@
|
|||||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.last\:border-b-0 {
|
||||||
|
&:last-child {
|
||||||
|
border-bottom-style: var(--tw-border-style);
|
||||||
|
border-bottom-width: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:-translate-y-0\.5 {
|
.hover\:-translate-y-0\.5 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -1662,6 +1875,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:bg-surface0\/50 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--surface0);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--surface0) 50%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-surface1 {
|
.hover\:bg-surface1 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -1848,6 +2071,16 @@
|
|||||||
--tw-ring-color: var(--mauve);
|
--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\:outline-none {
|
||||||
&:focus {
|
&:focus {
|
||||||
--tw-outline-style: none;
|
--tw-outline-style: none;
|
||||||
@@ -1967,6 +2200,11 @@
|
|||||||
width: calc(var(--spacing) * 10);
|
width: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:w-36 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
width: calc(var(--spacing) * 36);
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:w-auto {
|
.sm\:w-auto {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -2039,6 +2277,16 @@
|
|||||||
gap: calc(var(--spacing) * 2);
|
gap: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:gap-8 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
gap: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sm\:gap-10 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
gap: calc(var(--spacing) * 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:p-6 {
|
.sm\:p-6 {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
padding: calc(var(--spacing) * 6);
|
padding: calc(var(--spacing) * 6);
|
||||||
@@ -2069,12 +2317,30 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:text-2xl {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
line-height: var(--tw-leading, var(--text-2xl--line-height));
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:text-4xl {
|
.sm\:text-4xl {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
font-size: var(--text-4xl);
|
font-size: var(--text-4xl);
|
||||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:text-6xl {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
font-size: var(--text-6xl);
|
||||||
|
line-height: var(--tw-leading, var(--text-6xl--line-height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sm\:text-xl {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
line-height: var(--tw-leading, var(--text-xl--line-height));
|
||||||
|
}
|
||||||
|
}
|
||||||
.md\:col-span-2 {
|
.md\:col-span-2 {
|
||||||
@media (width >= 48rem) {
|
@media (width >= 48rem) {
|
||||||
grid-column: span 2 / span 2;
|
grid-column: span 2 / span 2;
|
||||||
@@ -2125,9 +2391,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.lg\:inline {
|
.lg\:w-auto {
|
||||||
@media (width >= 64rem) {
|
@media (width >= 64rem) {
|
||||||
display: inline;
|
width: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.lg\:grid-cols-2 {
|
.lg\:grid-cols-2 {
|
||||||
@@ -2140,9 +2406,9 @@
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.lg\:grid-cols-6 {
|
.lg\:flex-row {
|
||||||
@media (width >= 64rem) {
|
@media (width >= 64rem) {
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.lg\:items-end {
|
.lg\:items-end {
|
||||||
@@ -2170,6 +2436,16 @@
|
|||||||
gap: calc(var(--spacing) * 12);
|
gap: calc(var(--spacing) * 12);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.lg\:divide-x {
|
||||||
|
@media (width >= 64rem) {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-divide-x-reverse: 0;
|
||||||
|
border-inline-style: var(--tw-border-style);
|
||||||
|
border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));
|
||||||
|
border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.lg\:px-8 {
|
.lg\:px-8 {
|
||||||
@media (width >= 64rem) {
|
@media (width >= 64rem) {
|
||||||
padding-inline: calc(var(--spacing) * 8);
|
padding-inline: calc(var(--spacing) * 8);
|
||||||
@@ -2269,56 +2545,23 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: italic;
|
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-width: thin;
|
||||||
scrollbar-color: var(--surface1) var(--mantle);
|
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar {
|
.modal-scrollable {
|
||||||
width: 8px;
|
--scrollbar-thumb: var(--surface1);
|
||||||
height: 8px;
|
--scrollbar-track: var(--base);
|
||||||
}
|
scrollbar-width: thin;
|
||||||
::-webkit-scrollbar-track {
|
scrollbar-color: var(--scrollbar-thumb) var(--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);
|
|
||||||
}
|
}
|
||||||
@property --tw-translate-x {
|
@property --tw-translate-x {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
|
|||||||
152
internal/embedfs/web/js/bracket-lines.js
Normal file
152
internal/embedfs/web/js/bracket-lines.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// bracket-lines.js
|
||||||
|
// Draws smooth SVG bezier connector lines between series cards in a playoff bracket.
|
||||||
|
// Lines connect from the bottom-center of a source card to the top-center of a
|
||||||
|
// destination card. Winner paths are solid green, loser paths are dashed red.
|
||||||
|
//
|
||||||
|
// Usage: Add data-bracket-lines to a container element. Inside, series cards
|
||||||
|
// should have data-series="N" attributes. The container needs a data-connections
|
||||||
|
// attribute with a JSON array of connection objects:
|
||||||
|
// [{"from": 1, "to": 3, "type": "winner"}, ...]
|
||||||
|
//
|
||||||
|
// Optional toSide field ("left" or "right") makes the line arrive at the
|
||||||
|
// left or right edge (vertically centered) of the destination card instead
|
||||||
|
// of the top-center.
|
||||||
|
//
|
||||||
|
// An SVG element with data-bracket-svg inside the container is used for drawing.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var WINNER_COLOR = "var(--green)";
|
||||||
|
var LOSER_COLOR = "var(--red)";
|
||||||
|
var STROKE_WIDTH = 2;
|
||||||
|
var DASH_ARRAY = "6 3";
|
||||||
|
|
||||||
|
// Curvature control: how far the control points extend
|
||||||
|
// as a fraction of the total distance between cards
|
||||||
|
var CURVE_FACTOR = 0.4;
|
||||||
|
|
||||||
|
function drawBracketLines(container) {
|
||||||
|
var svg = container.querySelector("[data-bracket-svg]");
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
var connectionsAttr = container.getAttribute("data-connections");
|
||||||
|
if (!connectionsAttr) return;
|
||||||
|
|
||||||
|
var connections;
|
||||||
|
try {
|
||||||
|
connections = JSON.parse(connectionsAttr);
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing paths
|
||||||
|
while (svg.firstChild) {
|
||||||
|
svg.removeChild(svg.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get container position for relative coordinates
|
||||||
|
var containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Size SVG to match container
|
||||||
|
svg.setAttribute("width", containerRect.width);
|
||||||
|
svg.setAttribute("height", containerRect.height);
|
||||||
|
|
||||||
|
connections.forEach(function (conn) {
|
||||||
|
var fromCard = container.querySelector(
|
||||||
|
'[data-series="' + conn.from + '"]',
|
||||||
|
);
|
||||||
|
var toCard = container.querySelector('[data-series="' + conn.to + '"]');
|
||||||
|
if (!fromCard || !toCard) return;
|
||||||
|
|
||||||
|
var fromRect = fromCard.getBoundingClientRect();
|
||||||
|
var toRect = toCard.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Start: bottom-center of source card
|
||||||
|
var x1 = fromRect.left + fromRect.width / 2 - containerRect.left;
|
||||||
|
var y1 = fromRect.bottom - containerRect.top;
|
||||||
|
|
||||||
|
var x2, y2, d;
|
||||||
|
|
||||||
|
if (conn.toSide === "left") {
|
||||||
|
// End: left edge, vertically centered
|
||||||
|
x2 = toRect.left - containerRect.left;
|
||||||
|
y2 = toRect.top + toRect.height / 2 - containerRect.top;
|
||||||
|
|
||||||
|
// Bezier: go down first, then curve into the left side
|
||||||
|
var dy = y2 - y1;
|
||||||
|
var dx = x2 - x1;
|
||||||
|
d =
|
||||||
|
"M " + x1 + " " + y1 +
|
||||||
|
" C " + x1 + " " + (y1 + dy * 0.5) +
|
||||||
|
", " + (x2 + dx * 0.2) + " " + y2 +
|
||||||
|
", " + x2 + " " + y2;
|
||||||
|
} else if (conn.toSide === "right") {
|
||||||
|
// End: right edge, vertically centered
|
||||||
|
x2 = toRect.right - containerRect.left;
|
||||||
|
y2 = toRect.top + toRect.height / 2 - containerRect.top;
|
||||||
|
|
||||||
|
// Bezier: go down first, then curve into the right side
|
||||||
|
var dy = y2 - y1;
|
||||||
|
var dx = x2 - x1;
|
||||||
|
d =
|
||||||
|
"M " + x1 + " " + y1 +
|
||||||
|
" C " + x1 + " " + (y1 + dy * 0.5) +
|
||||||
|
", " + (x2 + dx * 0.2) + " " + y2 +
|
||||||
|
", " + x2 + " " + y2;
|
||||||
|
} else {
|
||||||
|
// Default: end at top-center of destination card
|
||||||
|
x2 = toRect.left + toRect.width / 2 - containerRect.left;
|
||||||
|
y2 = toRect.top - containerRect.top;
|
||||||
|
|
||||||
|
var dy = y2 - y1;
|
||||||
|
d =
|
||||||
|
"M " + x1 + " " + y1 +
|
||||||
|
" C " + x1 + " " + (y1 + dy * CURVE_FACTOR) +
|
||||||
|
", " + x2 + " " + (y2 - dy * CURVE_FACTOR) +
|
||||||
|
", " + x2 + " " + y2;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
path.setAttribute("d", d);
|
||||||
|
path.setAttribute("fill", "none");
|
||||||
|
path.setAttribute("stroke-width", STROKE_WIDTH);
|
||||||
|
|
||||||
|
if (conn.type === "winner") {
|
||||||
|
path.setAttribute("stroke", WINNER_COLOR);
|
||||||
|
} else {
|
||||||
|
path.setAttribute("stroke", LOSER_COLOR);
|
||||||
|
path.setAttribute("stroke-dasharray", DASH_ARRAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.appendChild(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAllBrackets() {
|
||||||
|
var containers = document.querySelectorAll("[data-bracket-lines]");
|
||||||
|
containers.forEach(drawBracketLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw on initial load
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", drawAllBrackets);
|
||||||
|
} else {
|
||||||
|
// DOM already loaded (e.g. script loaded via HTMX swap)
|
||||||
|
drawAllBrackets();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redraw on window resize (debounced)
|
||||||
|
var resizeTimer;
|
||||||
|
window.addEventListener("resize", function () {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(drawAllBrackets, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redraw after HTMX swaps
|
||||||
|
document.addEventListener("htmx:afterSwap", function () {
|
||||||
|
// Small delay to let the DOM settle
|
||||||
|
setTimeout(drawAllBrackets, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose for manual triggering if needed
|
||||||
|
window.drawBracketLines = drawAllBrackets;
|
||||||
|
})();
|
||||||
36
internal/embedfs/web/js/sortable-table.js
Normal file
36
internal/embedfs/web/js/sortable-table.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
function sortableTable(initField, initDir) {
|
||||||
|
return {
|
||||||
|
sortField: initField || "score",
|
||||||
|
sortDir: initDir || "desc",
|
||||||
|
|
||||||
|
sort(field) {
|
||||||
|
if (this.sortField === field) {
|
||||||
|
this.sortDir = this.sortDir === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
this.sortField = field;
|
||||||
|
this.sortDir = "desc";
|
||||||
|
}
|
||||||
|
this.reorder();
|
||||||
|
},
|
||||||
|
|
||||||
|
reorder() {
|
||||||
|
const tbody = this.$refs.tbody;
|
||||||
|
if (!tbody) return;
|
||||||
|
const rows = Array.from(tbody.querySelectorAll("tr"));
|
||||||
|
const field = this.sortField;
|
||||||
|
const dir = this.sortDir === "asc" ? 1 : -1;
|
||||||
|
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aVal = parseFloat(a.dataset[field]) || 0;
|
||||||
|
const bVal = parseFloat(b.dataset[field]) || 0;
|
||||||
|
if (aVal !== bVal) return (aVal - bVal) * dir;
|
||||||
|
// Tiebreak: alphabetical by player name
|
||||||
|
const aName = (a.dataset.name || "").toLowerCase();
|
||||||
|
const bName = (b.dataset.name || "").toLowerCase();
|
||||||
|
return aName < bName ? -1 : aName > bName ? 1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.forEach((row) => tbody.appendChild(row));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,8 +21,7 @@ import (
|
|||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FixtureDetailPage renders the fixture detail page with scheduling UI, history,
|
// FixtureDetailPage redirects to the default tab (overview)
|
||||||
// result display, and team rosters
|
|
||||||
func FixtureDetailPage(
|
func FixtureDetailPage(
|
||||||
s *hws.Server,
|
s *hws.Server,
|
||||||
conn *db.DB,
|
conn *db.DB,
|
||||||
@@ -33,9 +33,230 @@ func FixtureDetailPage(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
activeTab := r.URL.Query().Get("tab")
|
http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/overview", fixtureID), http.StatusSeeOther)
|
||||||
if activeTab == "" {
|
})
|
||||||
activeTab = "overview"
|
}
|
||||||
|
|
||||||
|
// 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
|
var fixture *db.Fixture
|
||||||
@@ -44,9 +265,6 @@ func FixtureDetailPage(
|
|||||||
var canSchedule bool
|
var canSchedule bool
|
||||||
var userTeamID int
|
var userTeamID int
|
||||||
var result *db.FixtureResult
|
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) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
@@ -71,38 +289,34 @@ func FixtureDetailPage(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "fixture.CanSchedule")
|
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||||
}
|
}
|
||||||
// Fetch fixture result if it exists
|
|
||||||
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
|
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.GetFixtureResult")
|
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
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSafely(seasonsview.FixtureDetailPage(
|
// If finalized, redirect to overview (scheduling tab is hidden)
|
||||||
fixture, currentSchedule, history, canSchedule, userTeamID,
|
if result != nil && result.Finalized {
|
||||||
result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents,
|
if r.Method == "GET" {
|
||||||
), s, r, w)
|
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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
95
internal/handlers/forfeit.go
Normal file
95
internal/handlers/forfeit.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,22 +1,85 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
homeview "git.haelnorr.com/h/oslstats/internal/view/homeview"
|
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
|
// Index handles responses to the / path. Also serves a 404 Page for paths that
|
||||||
// don't have explicit handlers
|
// 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(
|
return http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
throw.NotFound(s, w, r, 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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
104
internal/handlers/player_link_slapid.go
Normal file
104
internal/handlers/player_link_slapid.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
|
playersview "git.haelnorr.com/h/oslstats/internal/view/playersview"
|
||||||
|
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LinkPlayerSlapID handles the HTMX POST request to link a player's Slapshot ID
|
||||||
|
// via their Discord Steam connection. Only the player's owner can trigger this.
|
||||||
|
func LinkPlayerSlapID(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
slapAPI *slapshotapi.SlapAPI,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
playerIDStr := r.PathValue("player_id")
|
||||||
|
|
||||||
|
playerID, err := strconv.Atoi(playerIDStr)
|
||||||
|
if err != nil {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var player *db.Player
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
if user == nil {
|
||||||
|
throw.Unauthorized(s, w, r, "You must be logged in", errors.New("user not authenticated"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
player, err = db.GetPlayer(ctx, tx, playerID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the current user owns this player
|
||||||
|
if player.UserID == nil || *player.UserID != user.ID {
|
||||||
|
throw.ForbiddenSecurity(s, w, r, "You can only link your own player", errors.New("user does not own player"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player already has a SlapID
|
||||||
|
if player.SlapID != nil {
|
||||||
|
notify.Info(s, w, r, "Already Linked", "Your Slapshot ID is already linked", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the user's discord token to look up steam connection
|
||||||
|
discordToken, err := user.GetDiscordToken(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Link Failed", "Discord token not found. Please log out and log back in.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "user.GetDiscordToken")
|
||||||
|
}
|
||||||
|
|
||||||
|
audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user)
|
||||||
|
err = ConnectSlapID(ctx, tx, user, discordToken.Convert(), slapAPI, audit)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "ConnectSlapID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch the player to check if SlapID was set
|
||||||
|
player, err = db.GetPlayer(ctx, tx, playerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayer")
|
||||||
|
}
|
||||||
|
|
||||||
|
if player.SlapID == nil {
|
||||||
|
// ConnectSlapID returned nil (silent failure) - no steam or no slapID
|
||||||
|
notify.Warn(s, w, r, "Link Failed",
|
||||||
|
"Could not find your Slapshot ID. Make sure your Steam account is connected to Discord and you have played Slapshot: Rebound.",
|
||||||
|
nil)
|
||||||
|
} else {
|
||||||
|
notify.Success(s, w, r, "Success", "Your Slapshot ID has been linked!", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render the slap ID section with updated state
|
||||||
|
renderSafely(playersview.SlapIDSection(player, true), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
93
internal/handlers/player_stats_filter.go
Normal file
93
internal/handlers/player_stats_filter.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
playersview "git.haelnorr.com/h/oslstats/internal/view/playersview"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlayerStatsFilter handles HTMX POST requests to filter player stats
|
||||||
|
// by season or team. Only one filter can be active at a time.
|
||||||
|
// Query params: filter=season|team, filter_id=<id>
|
||||||
|
func PlayerStatsFilter(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
player, _, ok := resolvePlayerAndOwner(s, conn, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filterType := r.URL.Query().Get("filter")
|
||||||
|
filterIDStr := r.URL.Query().Get("filter_id")
|
||||||
|
|
||||||
|
var stats *db.PlayerAllTimeStats
|
||||||
|
var seasons []*db.Season
|
||||||
|
var teams []*db.Team
|
||||||
|
var activeFilter string
|
||||||
|
var activeFilterID int
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Load filter dropdown data
|
||||||
|
seasons, err = db.GetPlayerSeasonsList(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerSeasonsList")
|
||||||
|
}
|
||||||
|
teams, err = db.GetPlayerTeamsList(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerTeamsList")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
filterID, _ := strconv.Atoi(filterIDStr)
|
||||||
|
switch filterType {
|
||||||
|
case "season":
|
||||||
|
if filterID > 0 {
|
||||||
|
stats, err = db.GetPlayerStatsBySeason(ctx, tx, player.ID, filterID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerStatsBySeason")
|
||||||
|
}
|
||||||
|
activeFilter = "season"
|
||||||
|
activeFilterID = filterID
|
||||||
|
}
|
||||||
|
case "team":
|
||||||
|
if filterID > 0 {
|
||||||
|
stats, err = db.GetPlayerStatsByTeam(ctx, tx, player.ID, filterID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerStatsByTeam")
|
||||||
|
}
|
||||||
|
activeFilter = "team"
|
||||||
|
activeFilterID = filterID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to all-time stats if no valid filter
|
||||||
|
if stats == nil {
|
||||||
|
stats, err = db.GetPlayerAllTimeStats(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerAllTimeStats")
|
||||||
|
}
|
||||||
|
activeFilter = ""
|
||||||
|
activeFilterID = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSafely(playersview.PlayerStatsTab(
|
||||||
|
player, stats, seasons, teams,
|
||||||
|
activeFilter, activeFilterID,
|
||||||
|
), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
186
internal/handlers/player_view.go
Normal file
186
internal/handlers/player_view.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
|
playersview "git.haelnorr.com/h/oslstats/internal/view/playersview"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProfileRedirect redirects the authenticated user to their own player page.
|
||||||
|
func ProfileRedirect(
|
||||||
|
s *hws.Server,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := db.CurrentUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
throw.Unauthorized(s, w, r, "You must be logged in to view your profile", errors.New("user not authenticated"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Player == nil {
|
||||||
|
throw.InternalServiceError(s, w, r, "Player profile not found", errors.New("user has no linked player"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/players/%d", user.Player.ID), http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePlayerAndOwner is a helper that resolves the player from the URL path
|
||||||
|
// and determines if the current user is the owner of the player.
|
||||||
|
// Returns false from the outer handler if resolution failed (404 already thrown).
|
||||||
|
func resolvePlayerAndOwner(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
) (player *db.Player, isOwner bool, ok bool) {
|
||||||
|
playerIDStr := r.PathValue("player_id")
|
||||||
|
playerID, err := strconv.Atoi(playerIDStr)
|
||||||
|
if err != nil {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
player, err = db.GetPlayer(ctx, tx, playerID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayer")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
if user != nil && player.UserID != nil && *player.UserID == user.ID {
|
||||||
|
isOwner = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If player has no SlapID and viewer is not the owner, show 404
|
||||||
|
if player.SlapID == nil && !isOwner {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return player, isOwner, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerViewStats renders the player profile page with the stats tab active.
|
||||||
|
// GET renders the full page layout. POST renders just the tab content.
|
||||||
|
func PlayerViewStats(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats *db.PlayerAllTimeStats
|
||||||
|
var seasons []*db.Season
|
||||||
|
var teams []*db.Team
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
stats, err = db.GetPlayerAllTimeStats(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerAllTimeStats")
|
||||||
|
}
|
||||||
|
seasons, err = db.GetPlayerSeasonsList(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerSeasonsList")
|
||||||
|
}
|
||||||
|
teams, err = db.GetPlayerTeamsList(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerTeamsList")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
renderSafely(playersview.PlayerStatsPage(player, isOwner, stats, seasons, teams), s, r, w)
|
||||||
|
} else {
|
||||||
|
renderSafely(playersview.PlayerStatsTab(player, stats, seasons, teams, "", 0), s, r, w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerViewTeams renders the teams tab of the player profile page.
|
||||||
|
func PlayerViewTeams(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var teamInfos []*db.PlayerTeamInfo
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
teamInfos, err = db.GetPlayerTeams(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerTeams")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
renderSafely(playersview.PlayerTeamsPage(player, isOwner, teamInfos), s, r, w)
|
||||||
|
} else {
|
||||||
|
renderSafely(playersview.PlayerTeamsTab(teamInfos), s, r, w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerViewSeasons renders the seasons tab of the player profile page.
|
||||||
|
func PlayerViewSeasons(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var seasonInfos []*db.PlayerSeasonInfo
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
seasonInfos, err = db.GetPlayerSeasons(ctx, tx, player.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayerSeasons")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
renderSafely(playersview.PlayerSeasonsPage(player, isOwner, seasonInfos), s, r, w)
|
||||||
|
} else {
|
||||||
|
renderSafely(playersview.PlayerSeasonsTab(seasonInfos), s, r, w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -54,14 +54,14 @@ func Register(
|
|||||||
store.ClearRedirectTrack(r, "/register")
|
store.ClearRedirectTrack(r, "/register")
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(authview.RegisterPage(details.DiscordUser.Username), s, r, w)
|
renderSafely(authview.RegisterPage(""), s, r, w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
username := r.FormValue("username")
|
username := r.FormValue("username")
|
||||||
unique := false
|
unique := false
|
||||||
var user *db.User
|
var user *db.User
|
||||||
audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), 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)
|
unique, err = db.IsUnique(ctx, tx, (*db.User)(nil), "username", username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.IsUsernameUnique")
|
return false, errors.Wrap(err, "db.IsUsernameUnique")
|
||||||
@@ -73,12 +73,13 @@ func Register(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "registerUser")
|
return false, errors.Wrap(err, "registerUser")
|
||||||
}
|
}
|
||||||
err = connectSlapID(ctx, tx, user, details.Token, slapAPI, audit)
|
err = ConnectSlapID(ctx, tx, user, details.Token, slapAPI, audit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "connectSlapID")
|
return false, errors.Wrap(err, "connectSlapID")
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
|
throw.InternalServiceError(s, w, r, "Registration failed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !unique {
|
if !unique {
|
||||||
@@ -123,11 +124,11 @@ func registerUser(ctx context.Context, tx bun.Tx,
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectSlapID(ctx context.Context, tx bun.Tx, user *db.User,
|
// ConnectSlapID attempts to link a player's Slapshot ID via their Discord Steam connection.
|
||||||
|
// If fails due to no steam connection or no slapID, fails silently and returns nil.
|
||||||
|
func ConnectSlapID(ctx context.Context, tx bun.Tx, user *db.User,
|
||||||
token *discord.Token, slapAPI *slapshotapi.SlapAPI, audit *db.AuditMeta,
|
token *discord.Token, slapAPI *slapshotapi.SlapAPI, audit *db.AuditMeta,
|
||||||
) error {
|
) error {
|
||||||
// Attempt to setup their player/slapID from steam connection
|
|
||||||
// If fails due to no steam connection or no slapID, fail silently and proceed with registration
|
|
||||||
session, err := discord.NewOAuthSession(token)
|
session, err := discord.NewOAuthSession(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "discord.NewOAuthSession")
|
return errors.Wrap(err, "discord.NewOAuthSession")
|
||||||
|
|||||||
@@ -2,17 +2,27 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
"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/throw"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||||
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||||
|
"git.haelnorr.com/h/timefmt"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SeasonLeagueFinalsPage renders the finals tab of a season league page
|
// SeasonLeagueFinalsPage renders the finals tab of a season league page.
|
||||||
|
// Displays different content based on season status:
|
||||||
|
// - In Progress: "Regular Season in Progress" with optional "Begin Finals" button
|
||||||
|
// - Finals Soon/Finals/Completed: The playoff bracket + finals stats
|
||||||
func SeasonLeagueFinalsPage(
|
func SeasonLeagueFinalsPage(
|
||||||
s *hws.Server,
|
s *hws.Server,
|
||||||
conn *db.DB,
|
conn *db.DB,
|
||||||
@@ -21,11 +31,17 @@ func SeasonLeagueFinalsPage(
|
|||||||
seasonStr := r.PathValue("season_short_name")
|
seasonStr := r.PathValue("season_short_name")
|
||||||
leagueStr := r.PathValue("league_short_name")
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var sl *db.SeasonLeague
|
var season *db.Season
|
||||||
|
var league *db.League
|
||||||
|
var bracket *db.PlayoffBracket
|
||||||
|
var topGoals []*db.LeagueTopGoalScorer
|
||||||
|
var topAssists []*db.LeagueTopAssister
|
||||||
|
var topSaves []*db.LeagueTopSaver
|
||||||
|
var allStats []*db.LeaguePlayerStats
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -33,15 +49,286 @@ func SeasonLeagueFinalsPage(
|
|||||||
}
|
}
|
||||||
return false, errors.Wrap(err, "db.GetSeasonLeague")
|
return false, errors.Wrap(err, "db.GetSeasonLeague")
|
||||||
}
|
}
|
||||||
|
season = sl.Season
|
||||||
|
league = sl.League
|
||||||
|
|
||||||
|
// Try to load existing bracket
|
||||||
|
bracket, err = db.GetPlayoffBracket(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffBracket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load playoff stats if bracket exists
|
||||||
|
if bracket != nil {
|
||||||
|
topGoals, err = db.GetPlayoffTopGoalScorers(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffTopGoalScorers")
|
||||||
|
}
|
||||||
|
topAssists, err = db.GetPlayoffTopAssisters(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffTopAssisters")
|
||||||
|
}
|
||||||
|
topSaves, err = db.GetPlayoffTopSavers(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffTopSavers")
|
||||||
|
}
|
||||||
|
allStats, err = db.GetPlayoffPlayerStats(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffPlayerStats")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueFinalsPage(sl.Season, sl.League), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league, bracket, topGoals, topAssists, topSaves, allStats), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueFinals(), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFinals(season, league, bracket, topGoals, topAssists, topSaves, allStats), s, r, w)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeasonLeagueFinalsSetupForm renders the finals setup form via HTMX.
|
||||||
|
// Shows date pickers, format selection, unplayed fixture warnings, and standings preview.
|
||||||
|
func SeasonLeagueFinalsSetupForm(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seasonStr := r.PathValue("season_short_name")
|
||||||
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
|
var season *db.Season
|
||||||
|
var league *db.League
|
||||||
|
var leaderboard []*db.LeaderboardEntry
|
||||||
|
var unplayedFixtures []*db.Fixture
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
var teams []*db.Team
|
||||||
|
season, league, teams, err = db.GetSeasonLeagueWithTeams(ctx, tx, seasonStr, leagueStr)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetSeasonLeagueWithTeams")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get allocated fixtures and results for leaderboard
|
||||||
|
fixtures, err := db.GetAllocatedFixtures(ctx, tx, season.ID, league.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)
|
||||||
|
|
||||||
|
// Get unplayed fixtures
|
||||||
|
unplayedFixtures, err = db.GetUnplayedFixtures(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetUnplayedFixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSafely(seasonsview.FinalsSetupForm(
|
||||||
|
season, league, leaderboard, unplayedFixtures,
|
||||||
|
), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeasonLeagueFinalsSetupSubmit processes the finals setup form.
|
||||||
|
// It validates inputs, auto-forfeits unplayed fixtures, updates season dates,
|
||||||
|
// and generates the playoff bracket.
|
||||||
|
func SeasonLeagueFinalsSetupSubmit(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seasonStr := r.PathValue("season_short_name")
|
||||||
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
format := timefmt.NewBuilder().
|
||||||
|
DayNumeric2().Slash().
|
||||||
|
MonthNumeric2().Slash().
|
||||||
|
Year4().Build()
|
||||||
|
|
||||||
|
endDate := getter.Time("regular_season_end_date", format).Required().Value
|
||||||
|
finalsStartDate := getter.Time("finals_start_date", format).Required().Value
|
||||||
|
playoffFormat := getter.String("format").TrimSpace().Required().
|
||||||
|
AllowedValues([]string{
|
||||||
|
string(db.PlayoffFormat5to6),
|
||||||
|
string(db.PlayoffFormat7to9),
|
||||||
|
string(db.PlayoffFormat10to15),
|
||||||
|
}).Value
|
||||||
|
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate finals start is after end date
|
||||||
|
if !finalsStartDate.After(endDate) && !finalsStartDate.Equal(endDate) {
|
||||||
|
notify.Warn(s, w, r, "Invalid Dates",
|
||||||
|
"Finals start date must be on or after the regular season end date.", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse per-round BO configuration from form fields
|
||||||
|
roundFormats := parseRoundFormats(r, db.PlayoffFormat(playoffFormat))
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
// Get season, league, teams
|
||||||
|
var teams []*db.Team
|
||||||
|
season, league, teams, err := db.GetSeasonLeagueWithTeams(ctx, tx, seasonStr, leagueStr)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
respond.NotFound(w, err)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetSeasonLeagueWithTeams")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check no bracket already exists
|
||||||
|
existing, err := db.GetPlayoffBracket(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffBracket")
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
notify.Warn(s, w, r, "Already Exists",
|
||||||
|
"A playoff bracket already exists for this league.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
audit := db.NewAuditFromRequest(r)
|
||||||
|
|
||||||
|
// Auto-forfeit unplayed fixtures
|
||||||
|
forfeitCount, err := db.AutoForfeitUnplayedFixtures(
|
||||||
|
ctx, tx, season.ID, league.ID, user.ID, audit)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.AutoForfeitUnplayedFixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update season dates
|
||||||
|
err = season.Update(ctx, tx,
|
||||||
|
season.SlapVersion,
|
||||||
|
season.StartDate,
|
||||||
|
endDate,
|
||||||
|
finalsStartDate,
|
||||||
|
season.FinalsEndDate.Time,
|
||||||
|
audit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "season.Update")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute final leaderboard (after forfeits)
|
||||||
|
fixtures, err := db.GetAllocatedFixtures(ctx, tx, season.ID, league.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)
|
||||||
|
|
||||||
|
// Generate the bracket
|
||||||
|
_, err = db.GeneratePlayoffBracket(
|
||||||
|
ctx, tx,
|
||||||
|
season.ID, league.ID,
|
||||||
|
db.PlayoffFormat(playoffFormat),
|
||||||
|
leaderboard,
|
||||||
|
roundFormats,
|
||||||
|
audit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Create Bracket", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GeneratePlayoffBracket")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = forfeitCount
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("/seasons/%s/leagues/%s/finals", seasonStr, leagueStr)
|
||||||
|
respond.HXRedirect(w, "%s", url)
|
||||||
|
notify.SuccessWithDelay(s, w, r, "Finals Created",
|
||||||
|
"Playoff bracket has been generated successfully.", nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRoundFormats reads bo_<round> form fields and returns a map of round name
|
||||||
|
// to matches_to_win value (1 = BO1, 2 = BO3, 3 = BO5).
|
||||||
|
// Form fields are named like "bo_grand_final", "bo_semi_final", etc.
|
||||||
|
func parseRoundFormats(r *http.Request, format db.PlayoffFormat) map[string]int {
|
||||||
|
roundFormats := make(map[string]int)
|
||||||
|
|
||||||
|
var rounds []string
|
||||||
|
switch format {
|
||||||
|
case db.PlayoffFormat5to6:
|
||||||
|
rounds = []string{
|
||||||
|
"upper_bracket", "lower_bracket",
|
||||||
|
"upper_final", "lower_final",
|
||||||
|
"grand_final",
|
||||||
|
}
|
||||||
|
case db.PlayoffFormat7to9:
|
||||||
|
rounds = []string{
|
||||||
|
"quarter_final", "semi_final",
|
||||||
|
"third_place", "grand_final",
|
||||||
|
}
|
||||||
|
case db.PlayoffFormat10to15:
|
||||||
|
rounds = []string{
|
||||||
|
"qualifying_final", "elimination_final",
|
||||||
|
"semi_final", "preliminary_final",
|
||||||
|
"third_place", "grand_final",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, round := range rounds {
|
||||||
|
val := r.FormValue("bo_" + round)
|
||||||
|
if val == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mtw, err := strconv.Atoi(val)
|
||||||
|
if err != nil || mtw < 1 || mtw > 3 {
|
||||||
|
continue // Invalid values default to BO1 in getMatchesToWin
|
||||||
|
}
|
||||||
|
roundFormats[round] = mtw
|
||||||
|
}
|
||||||
|
|
||||||
|
return roundFormats
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ func SeasonLeagueStatsPage(
|
|||||||
leagueStr := r.PathValue("league_short_name")
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var sl *db.SeasonLeague
|
var sl *db.SeasonLeague
|
||||||
|
var topGoals []*db.LeagueTopGoalScorer
|
||||||
|
var topAssists []*db.LeagueTopAssister
|
||||||
|
var topSaves []*db.LeagueTopSaver
|
||||||
|
var allStats []*db.LeaguePlayerStats
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
@@ -33,15 +37,36 @@ func SeasonLeagueStatsPage(
|
|||||||
}
|
}
|
||||||
return false, errors.Wrap(err, "db.GetSeasonLeague")
|
return false, errors.Wrap(err, "db.GetSeasonLeague")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
topGoals, err = db.GetTopGoalScorers(ctx, tx, sl.SeasonID, sl.LeagueID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetTopGoalScorers")
|
||||||
|
}
|
||||||
|
|
||||||
|
topAssists, err = db.GetTopAssisters(ctx, tx, sl.SeasonID, sl.LeagueID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetTopAssisters")
|
||||||
|
}
|
||||||
|
|
||||||
|
topSaves, err = db.GetTopSavers(ctx, tx, sl.SeasonID, sl.LeagueID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetTopSavers")
|
||||||
|
}
|
||||||
|
|
||||||
|
allStats, err = db.GetAllLeaguePlayerStats(ctx, tx, sl.SeasonID, sl.LeagueID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetAllLeaguePlayerStats")
|
||||||
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League, topGoals, topAssists, topSaves, allStats), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueStats(), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueStats(sl.Season, sl.League, topGoals, topAssists, topSaves, allStats), s, r, w)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func SeasonLeagueTablePage(
|
|||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueTablePage(season, league, leaderboard), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueTablePage(season, league, leaderboard), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueTable(leaderboard), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueTable(season, league, leaderboard), s, r, w)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func SeasonLeagueTeamDetailPage(
|
|||||||
var scheduleMap map[int]*db.FixtureSchedule
|
var scheduleMap map[int]*db.FixtureSchedule
|
||||||
var resultMap map[int]*db.FixtureResult
|
var resultMap map[int]*db.FixtureResult
|
||||||
var playerStats []*db.AggregatedPlayerStats
|
var playerStats []*db.AggregatedPlayerStats
|
||||||
|
var leaderboard []*db.LeaderboardEntry
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
@@ -72,12 +73,51 @@ func SeasonLeagueTeamDetailPage(
|
|||||||
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
|
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all teams and all fixtures for the league to compute leaderboard
|
||||||
|
var allTeams []*db.Team
|
||||||
|
err = tx.NewSelect().
|
||||||
|
Model(&allTeams).
|
||||||
|
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
|
||||||
|
Where("tp.season_id = ? AND tp.league_id = ?", twr.Season.ID, twr.League.ID).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "tx.NewSelect allTeams")
|
||||||
|
}
|
||||||
|
|
||||||
|
allFixtures, err := db.GetAllocatedFixtures(ctx, tx, twr.Season.ID, twr.League.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetAllocatedFixtures")
|
||||||
|
}
|
||||||
|
allFixtureIDs := make([]int, len(allFixtures))
|
||||||
|
for i, f := range allFixtures {
|
||||||
|
allFixtureIDs[i] = f.ID
|
||||||
|
}
|
||||||
|
allResultMap, err := db.GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures allFixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
leaderboard = db.ComputeLeaderboard(allTeams, allFixtures, allResultMap)
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
record := db.ComputeTeamRecord(teamID, fixtures, resultMap)
|
// Find this team's position and record from the leaderboard
|
||||||
renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available, scheduleMap, resultMap, record, playerStats), s, r, w)
|
var position int
|
||||||
|
var record *db.TeamRecord
|
||||||
|
for _, entry := range leaderboard {
|
||||||
|
if entry.Team.ID == teamID {
|
||||||
|
position = entry.Position
|
||||||
|
record = entry.Record
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if record == nil {
|
||||||
|
record = &db.TeamRecord{}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available, scheduleMap, resultMap, record, playerStats, position, len(leaderboard)), s, r, w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
306
internal/handlers/series_detail.go
Normal file
306
internal/handlers/series_detail.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
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/respond"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeriesDetailPage redirects to the default tab (overview)
|
||||||
|
func SeriesDetailPage(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/series/%d/overview", seriesID), http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesDetailOverviewPage renders the overview tab of the series detail page
|
||||||
|
func SeriesDetailOverviewPage(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var series *db.PlayoffSeries
|
||||||
|
var currentSchedule *db.PlayoffSeriesSchedule
|
||||||
|
var canSchedule bool
|
||||||
|
var userTeamID int
|
||||||
|
var rosters map[string][]*db.PlayerWithPlayStatus
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, userTeamID, err = db.CanScheduleSeries(ctx, tx, series, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
||||||
|
}
|
||||||
|
|
||||||
|
rosters, err = db.GetSeriesTeamRosters(ctx, tx, series)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetSeriesTeamRosters")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
renderSafely(seasonsview.SeriesDetailOverviewPage(
|
||||||
|
series, currentSchedule, canSchedule, userTeamID, rosters,
|
||||||
|
), s, r, w)
|
||||||
|
} else {
|
||||||
|
renderSafely(seasonsview.SeriesDetailOverviewContent(
|
||||||
|
series, currentSchedule, canSchedule, userTeamID, rosters,
|
||||||
|
), s, r, w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesDetailPreviewPage renders the match preview tab of the series detail page
|
||||||
|
func SeriesDetailPreviewPage(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var series *db.PlayoffSeries
|
||||||
|
var currentSchedule *db.PlayoffSeriesSchedule
|
||||||
|
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
|
||||||
|
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule")
|
||||||
|
}
|
||||||
|
|
||||||
|
rosters, err = db.GetSeriesTeamRosters(ctx, tx, series)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetSeriesTeamRosters")
|
||||||
|
}
|
||||||
|
|
||||||
|
previewData, err = db.ComputeSeriesPreview(ctx, tx, series)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.ComputeSeriesPreview")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If completed, redirect to analysis instead
|
||||||
|
if series.Status == db.SeriesStatusCompleted {
|
||||||
|
if r.Method == "GET" {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/series/%d/analysis", seriesID), http.StatusSeeOther)
|
||||||
|
} else {
|
||||||
|
respond.HXRedirect(w, "/series/%d/analysis", seriesID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
renderSafely(seasonsview.SeriesDetailPreviewPage(
|
||||||
|
series, currentSchedule, rosters, previewData,
|
||||||
|
), s, r, w)
|
||||||
|
} else {
|
||||||
|
renderSafely(seasonsview.SeriesDetailPreviewContent(
|
||||||
|
series, rosters, previewData,
|
||||||
|
), s, r, w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesDetailAnalysisPage renders the match analysis tab of the series detail page
|
||||||
|
func SeriesDetailAnalysisPage(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var series *db.PlayoffSeries
|
||||||
|
var currentSchedule *db.PlayoffSeriesSchedule
|
||||||
|
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
|
||||||
|
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule")
|
||||||
|
}
|
||||||
|
|
||||||
|
rosters, err = db.GetSeriesTeamRosters(ctx, tx, series)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetSeriesTeamRosters")
|
||||||
|
}
|
||||||
|
|
||||||
|
previewData, err = db.ComputeSeriesPreview(ctx, tx, series)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.ComputeSeriesPreview")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not completed, redirect to preview instead
|
||||||
|
if series.Status != db.SeriesStatusCompleted {
|
||||||
|
if r.Method == "GET" {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/series/%d/preview", seriesID), http.StatusSeeOther)
|
||||||
|
} else {
|
||||||
|
respond.HXRedirect(w, "/series/%d/preview", seriesID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
renderSafely(seasonsview.SeriesDetailAnalysisPage(
|
||||||
|
series, currentSchedule, rosters, previewData,
|
||||||
|
), s, r, w)
|
||||||
|
} else {
|
||||||
|
renderSafely(seasonsview.SeriesDetailAnalysisContent(
|
||||||
|
series, rosters, previewData,
|
||||||
|
), s, r, w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesDetailSchedulePage renders the schedule tab of the series detail page
|
||||||
|
func SeriesDetailSchedulePage(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var series *db.PlayoffSeries
|
||||||
|
var currentSchedule *db.PlayoffSeriesSchedule
|
||||||
|
var history []*db.PlayoffSeriesSchedule
|
||||||
|
var canSchedule bool
|
||||||
|
var userTeamID int
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule")
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err = db.GetSeriesScheduleHistory(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetSeriesScheduleHistory")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, userTeamID, err = db.CanScheduleSeries(ctx, tx, series, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If completed, redirect to overview
|
||||||
|
if series.Status == db.SeriesStatusCompleted {
|
||||||
|
if r.Method == "GET" {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/series/%d/overview", seriesID), http.StatusSeeOther)
|
||||||
|
} else {
|
||||||
|
respond.HXRedirect(w, "/series/%d/overview", seriesID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
renderSafely(seasonsview.SeriesDetailSchedulePage(
|
||||||
|
series, currentSchedule, history, canSchedule, userTeamID,
|
||||||
|
), s, r, w)
|
||||||
|
} else {
|
||||||
|
renderSafely(seasonsview.SeriesDetailScheduleContent(
|
||||||
|
series, currentSchedule, history, canSchedule, userTeamID,
|
||||||
|
), s, r, w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
502
internal/handlers/series_result.go
Normal file
502
internal/handlers/series_result.go
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"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/view/seasonsview"
|
||||||
|
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeriesUploadResultPage renders the upload form for series match logs
|
||||||
|
func SeriesUploadResultPage(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var series *db.PlayoffSeries
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing pending results
|
||||||
|
hasPending, err := db.HasPendingSeriesResults(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.HasPendingSeriesResults")
|
||||||
|
}
|
||||||
|
if hasPending {
|
||||||
|
throw.BadRequest(s, w, r, "Pending results already exist for this series. Discard them first to re-upload.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSafely(seasonsview.SeriesUploadResultPage(series), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesUploadResults handles POST /series/{series_id}/results/upload
|
||||||
|
// Parses match logs for all games, creates fixtures + results.
|
||||||
|
func SeriesUploadResults(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multipart form
|
||||||
|
err = r.ParseMultipartForm(maxUploadSize * 5) // up to 5 games worth
|
||||||
|
if err != nil {
|
||||||
|
notify.Warn(s, w, r, "Upload Failed", "Could not parse uploaded files.", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gameCountStr := r.FormValue("game_count")
|
||||||
|
gameCount, err := strconv.Atoi(gameCountStr)
|
||||||
|
if err != nil || gameCount < 1 {
|
||||||
|
notify.Warn(s, w, r, "Invalid Input", "Please select a valid number of games.", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse all game logs
|
||||||
|
type gameLogs struct {
|
||||||
|
Logs [3]*slapshotapi.MatchLog
|
||||||
|
}
|
||||||
|
allGameLogs := make([]*gameLogs, gameCount)
|
||||||
|
|
||||||
|
for g := 1; g <= gameCount; g++ {
|
||||||
|
gl := &gameLogs{}
|
||||||
|
for p := 1; p <= 3; p++ {
|
||||||
|
fieldName := "game_" + strconv.Itoa(g) + "_period_" + strconv.Itoa(p)
|
||||||
|
file, _, err := r.FormFile(fieldName)
|
||||||
|
if err != nil {
|
||||||
|
notify.Warn(s, w, r, "Missing File",
|
||||||
|
"All 3 period files are required for Game "+strconv.Itoa(g)+". Missing period "+strconv.Itoa(p)+".", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
notify.Warn(s, w, r, "Read Error", "Could not read file: "+fieldName, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log, err := slapshotapi.ParseMatchLog(data)
|
||||||
|
if err != nil {
|
||||||
|
notify.Warn(s, w, r, "Parse Error",
|
||||||
|
"Could not parse Game "+strconv.Itoa(g)+" Period "+strconv.Itoa(p)+": "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gl.Logs[p-1] = log
|
||||||
|
}
|
||||||
|
allGameLogs[g-1] = gl
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
respond.NotFound(w, errors.New("series not found"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate game count
|
||||||
|
maxGames := series.MatchesToWin*2 - 1
|
||||||
|
if gameCount < series.MatchesToWin || gameCount > maxGames {
|
||||||
|
notify.Warn(s, w, r, "Invalid Game Count",
|
||||||
|
"Game count must be between "+strconv.Itoa(series.MatchesToWin)+" and "+strconv.Itoa(maxGames)+".", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing pending results
|
||||||
|
hasPending, err := db.HasPendingSeriesResults(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.HasPendingSeriesResults")
|
||||||
|
}
|
||||||
|
if hasPending {
|
||||||
|
notify.Warn(s, w, r, "Results Exist", "Pending results already exist. Discard them first.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
audit := db.NewAuditFromRequest(r)
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
|
||||||
|
// Process each game
|
||||||
|
team1Wins := 0
|
||||||
|
team2Wins := 0
|
||||||
|
|
||||||
|
for g := 0; g < gameCount; g++ {
|
||||||
|
gl := allGameLogs[g]
|
||||||
|
logs := []*slapshotapi.MatchLog{gl.Logs[0], gl.Logs[1], gl.Logs[2]}
|
||||||
|
matchNumber := g + 1
|
||||||
|
|
||||||
|
// Check if series is already decided
|
||||||
|
if team1Wins >= series.MatchesToWin || team2Wins >= series.MatchesToWin {
|
||||||
|
notify.Warn(s, w, r, "Too Many Games",
|
||||||
|
"The series was already decided before Game "+strconv.Itoa(matchNumber)+". Reduce the game count.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect tampering
|
||||||
|
tamperingDetected, tamperingReason, err := slapshotapi.DetectTampering(logs)
|
||||||
|
if err != nil {
|
||||||
|
notify.Warn(s, w, r, "Validation Error",
|
||||||
|
"Game "+strconv.Itoa(matchNumber)+" tampering check failed: "+err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create fixture for this game
|
||||||
|
fixture, _, err := db.CreatePlayoffGameFixture(ctx, tx, series, matchNumber, audit)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.CreatePlayoffGameFixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect game_user_ids
|
||||||
|
gameUserIDSet := map[string]bool{}
|
||||||
|
for _, log := range logs {
|
||||||
|
for _, p := range log.Players {
|
||||||
|
gameUserIDSet[p.GameUserID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gameUserIDs := make([]string, 0, len(gameUserIDSet))
|
||||||
|
for id := range gameUserIDSet {
|
||||||
|
gameUserIDs = append(gameUserIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map players
|
||||||
|
playerLookup, err := MapGameUserIDsToPlayers(ctx, tx, gameUserIDs, fixture.SeasonID, fixture.LeagueID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "MapGameUserIDsToPlayers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine orientation
|
||||||
|
allPlayers := logs[2].Players
|
||||||
|
fixtureHomeIsLogsHome, _, err := DetermineTeamOrientation(ctx, tx, fixture, allPlayers, playerLookup)
|
||||||
|
if err != nil {
|
||||||
|
notify.Warn(s, w, r, "Orientation Error",
|
||||||
|
"Game "+strconv.Itoa(matchNumber)+": Could not determine team orientation: "+err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result
|
||||||
|
finalLog := logs[2]
|
||||||
|
winner := finalLog.Winner
|
||||||
|
homeScore := finalLog.Score.Home
|
||||||
|
awayScore := finalLog.Score.Away
|
||||||
|
if !fixtureHomeIsLogsHome {
|
||||||
|
switch winner {
|
||||||
|
case "home":
|
||||||
|
winner = "away"
|
||||||
|
case "away":
|
||||||
|
winner = "home"
|
||||||
|
}
|
||||||
|
homeScore, awayScore = awayScore, homeScore
|
||||||
|
}
|
||||||
|
|
||||||
|
periodsEnabled := finalLog.PeriodsEnabled == "True"
|
||||||
|
customMercyRule, _ := strconv.Atoi(finalLog.CustomMercyRule)
|
||||||
|
matchLength, _ := strconv.Atoi(finalLog.MatchLength)
|
||||||
|
|
||||||
|
var tamperingReasonPtr *string
|
||||||
|
if tamperingDetected {
|
||||||
|
tamperingReasonPtr = &tamperingReason
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &db.FixtureResult{
|
||||||
|
FixtureID: fixture.ID,
|
||||||
|
Winner: winner,
|
||||||
|
HomeScore: homeScore,
|
||||||
|
AwayScore: awayScore,
|
||||||
|
MatchType: finalLog.Type,
|
||||||
|
Arena: finalLog.Arena,
|
||||||
|
EndReason: finalLog.EndReason,
|
||||||
|
PeriodsEnabled: periodsEnabled,
|
||||||
|
CustomMercyRule: customMercyRule,
|
||||||
|
MatchLength: matchLength,
|
||||||
|
UploadedByUserID: user.ID,
|
||||||
|
Finalized: false,
|
||||||
|
TamperingDetected: tamperingDetected,
|
||||||
|
TamperingReason: tamperingReasonPtr,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build player stats
|
||||||
|
playerStats := []*db.FixtureResultPlayerStats{}
|
||||||
|
for periodIdx, log := range logs {
|
||||||
|
periodNum := periodIdx + 1
|
||||||
|
for _, p := range log.Players {
|
||||||
|
team := p.Team
|
||||||
|
if !fixtureHomeIsLogsHome {
|
||||||
|
if team == "home" {
|
||||||
|
team = "away"
|
||||||
|
} else {
|
||||||
|
team = "home"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerID *int
|
||||||
|
var teamID *int
|
||||||
|
if lookup, ok := playerLookup[p.GameUserID]; ok && lookup.Found {
|
||||||
|
playerID = &lookup.Player.ID
|
||||||
|
if !lookup.Unmapped {
|
||||||
|
teamID = &lookup.TeamID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stat := &db.FixtureResultPlayerStats{
|
||||||
|
PeriodNum: periodNum,
|
||||||
|
PlayerID: playerID,
|
||||||
|
PlayerGameUserID: p.GameUserID,
|
||||||
|
PlayerUsername: p.Username,
|
||||||
|
TeamID: teamID,
|
||||||
|
Team: team,
|
||||||
|
Goals: FloatToIntPtr(p.Stats.Goals),
|
||||||
|
Assists: FloatToIntPtr(p.Stats.Assists),
|
||||||
|
PrimaryAssists: FloatToIntPtr(p.Stats.PrimaryAssists),
|
||||||
|
SecondaryAssists: FloatToIntPtr(p.Stats.SecondaryAssists),
|
||||||
|
Saves: FloatToIntPtr(p.Stats.Saves),
|
||||||
|
Blocks: FloatToIntPtr(p.Stats.Blocks),
|
||||||
|
Shots: FloatToIntPtr(p.Stats.Shots),
|
||||||
|
Turnovers: FloatToIntPtr(p.Stats.Turnovers),
|
||||||
|
Takeaways: FloatToIntPtr(p.Stats.Takeaways),
|
||||||
|
Passes: FloatToIntPtr(p.Stats.Passes),
|
||||||
|
PossessionTimeSec: FloatToIntPtr(p.Stats.PossessionTime),
|
||||||
|
FaceoffsWon: FloatToIntPtr(p.Stats.FaceoffsWon),
|
||||||
|
FaceoffsLost: FloatToIntPtr(p.Stats.FaceoffsLost),
|
||||||
|
PostHits: FloatToIntPtr(p.Stats.PostHits),
|
||||||
|
OvertimeGoals: FloatToIntPtr(p.Stats.OvertimeGoals),
|
||||||
|
GameWinningGoals: FloatToIntPtr(p.Stats.GameWinningGoals),
|
||||||
|
Score: FloatToIntPtr(p.Stats.Score),
|
||||||
|
ContributedGoals: FloatToIntPtr(p.Stats.ContributedGoals),
|
||||||
|
ConcededGoals: FloatToIntPtr(p.Stats.ConcededGoals),
|
||||||
|
GamesPlayed: FloatToIntPtr(p.Stats.GamesPlayed),
|
||||||
|
Wins: FloatToIntPtr(p.Stats.Wins),
|
||||||
|
Losses: FloatToIntPtr(p.Stats.Losses),
|
||||||
|
OvertimeWins: FloatToIntPtr(p.Stats.OvertimeWins),
|
||||||
|
OvertimeLosses: FloatToIntPtr(p.Stats.OvertimeLosses),
|
||||||
|
Ties: FloatToIntPtr(p.Stats.Ties),
|
||||||
|
Shutouts: FloatToIntPtr(p.Stats.Shutouts),
|
||||||
|
ShutoutsAgainst: FloatToIntPtr(p.Stats.ShutsAgainst),
|
||||||
|
HasMercyRuled: FloatToIntPtr(p.Stats.HasMercyRuled),
|
||||||
|
WasMercyRuled: FloatToIntPtr(p.Stats.WasMercyRuled),
|
||||||
|
PeriodsPlayed: FloatToIntPtr(p.Stats.PeriodsPlayed),
|
||||||
|
}
|
||||||
|
playerStats = append(playerStats, stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark free agents
|
||||||
|
for _, ps := range playerStats {
|
||||||
|
if ps.PlayerID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
isFA, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, *ps.PlayerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
|
||||||
|
}
|
||||||
|
if isFA {
|
||||||
|
ps.IsFreeAgent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert result
|
||||||
|
_, err = db.InsertFixtureResult(ctx, tx, result, playerStats, audit)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.InsertFixtureResult")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track wins: home = team1, away = team2
|
||||||
|
if winner == "home" {
|
||||||
|
team1Wins++
|
||||||
|
} else {
|
||||||
|
team2Wins++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the series result is valid
|
||||||
|
if team1Wins < series.MatchesToWin && team2Wins < series.MatchesToWin {
|
||||||
|
notify.Warn(s, w, r, "Incomplete Series",
|
||||||
|
"Neither team has enough wins to decide the series. More games are needed.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond.HXRedirect(w, "/series/%d/results/review", seriesID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesReviewResults handles GET /series/{series_id}/results/review
|
||||||
|
func SeriesReviewResults(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var series *db.PlayoffSeries
|
||||||
|
var gameResults []*seasonsview.SeriesGameResult
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build game results from matches
|
||||||
|
for _, match := range series.Matches {
|
||||||
|
if match.FixtureID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := db.GetPendingFixtureResult(ctx, tx, *match.FixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPendingFixtureResult")
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
gr := &seasonsview.SeriesGameResult{
|
||||||
|
GameNumber: match.MatchNumber,
|
||||||
|
Result: result,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build unmapped players and FA warnings
|
||||||
|
for _, ps := range result.PlayerStats {
|
||||||
|
if ps.PeriodNum != 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ps.PlayerID == nil {
|
||||||
|
gr.UnmappedPlayers = append(gr.UnmappedPlayers,
|
||||||
|
ps.PlayerGameUserID+" ("+ps.PlayerUsername+")")
|
||||||
|
} else if ps.IsFreeAgent {
|
||||||
|
gr.FreeAgentWarnings = append(gr.FreeAgentWarnings, seasonsview.FreeAgentWarning{
|
||||||
|
Name: ps.PlayerUsername,
|
||||||
|
Reason: "free agent in playoff match",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gameResults = append(gameResults, gr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gameResults) == 0 {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSafely(seasonsview.SeriesReviewResultPage(series, gameResults), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesFinalizeResults handles POST /series/{series_id}/results/finalize
|
||||||
|
func SeriesFinalizeResults(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
_, err := db.FinalizeSeriesResults(ctx, tx, seriesID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Finalize", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.FinalizeSeriesResults")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.SuccessWithDelay(s, w, r, "Series Finalized", "All game results have been finalized and the series is complete.", nil)
|
||||||
|
respond.HXRedirect(w, "/series/%d", seriesID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesDiscardResults handles POST /series/{series_id}/results/discard
|
||||||
|
func SeriesDiscardResults(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
err := db.DeleteSeriesResults(ctx, tx, seriesID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Discard", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.DeleteSeriesResults")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Results Discarded", "All uploaded results have been discarded. You can upload new logs.", nil)
|
||||||
|
respond.HXRedirect(w, "/series/%d", seriesID)
|
||||||
|
})
|
||||||
|
}
|
||||||
410
internal/handlers/series_schedule.go
Normal file
410
internal/handlers/series_schedule.go
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"git.haelnorr.com/h/timefmt"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProposeSeriesSchedule handles POST /series/{series_id}/schedule
|
||||||
|
func ProposeSeriesSchedule(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
|
||||||
|
DayNumeric2().T().Hour24().Colon().Minute().Build()
|
||||||
|
aest, _ := time.LoadLocation("Australia/Sydney")
|
||||||
|
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).Value
|
||||||
|
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
respond.NotFound(w, errors.New("series not found"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to propose a schedule", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ProposeSeriesSchedule(ctx, tx, seriesID, userTeamID, scheduledTime, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Propose", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.ProposeSeriesSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.SuccessWithDelay(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil)
|
||||||
|
respond.HXRedirect(w, "/series/%d", seriesID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptSeriesSchedule handles POST /series/{series_id}/schedule/{schedule_id}/accept
|
||||||
|
func AcceptSeriesSchedule(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
respond.NotFound(w, errors.New("series not found"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to accept a schedule", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.AcceptSeriesSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Accept", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.AcceptSeriesSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Schedule Accepted", "The series time has been confirmed.", nil)
|
||||||
|
respond.HXRedirect(w, "/series/%d", seriesID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectSeriesSchedule handles POST /series/{series_id}/schedule/{schedule_id}/reject
|
||||||
|
func RejectSeriesSchedule(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
respond.NotFound(w, errors.New("series not found"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, _, err := db.CanScheduleSeries(ctx, tx, series, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to reject a schedule", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.RejectSeriesSchedule(ctx, tx, scheduleID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Reject", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.RejectSeriesSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Schedule Rejected", "The proposed time has been rejected.", nil)
|
||||||
|
respond.HXRedirect(w, "/series/%d", seriesID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostponeSeriesSchedule handles POST /series/{series_id}/schedule/postpone
|
||||||
|
func PostponeSeriesSchedule(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
respond.NotFound(w, errors.New("series not found"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, _, err := db.CanScheduleSeries(ctx, tx, series, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to postpone a series", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.PostponeSeriesSchedule(ctx, tx, seriesID, reason, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Postpone", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.PostponeSeriesSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Series Postponed", "The series has been postponed.", nil)
|
||||||
|
respond.HXRedirect(w, "/series/%d", seriesID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RescheduleSeriesHandler handles POST /series/{series_id}/schedule/reschedule
|
||||||
|
func RescheduleSeriesHandler(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
|
||||||
|
DayNumeric2().T().Hour24().Colon().Minute().Build()
|
||||||
|
aest, _ := time.LoadLocation("Australia/Sydney")
|
||||||
|
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value
|
||||||
|
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
|
||||||
|
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
respond.NotFound(w, errors.New("series not found"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to reschedule a series", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.RescheduleSeriesSchedule(ctx, tx, seriesID, userTeamID, scheduledTime, reason, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Reschedule", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.RescheduleSeriesSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Series Rescheduled", "The new proposed time has been submitted.", nil)
|
||||||
|
respond.HXRedirect(w, "/series/%d", seriesID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithdrawSeriesScheduleHandler handles POST /series/{series_id}/schedule/{schedule_id}/withdraw
|
||||||
|
func WithdrawSeriesScheduleHandler(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||||
|
}
|
||||||
|
if series == nil {
|
||||||
|
respond.NotFound(w, errors.New("series not found"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to withdraw a proposal", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WithdrawSeriesSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Withdraw", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.WithdrawSeriesSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Proposal Withdrawn", "Your proposed time has been withdrawn.", nil)
|
||||||
|
respond.HXRedirect(w, "/series/%d", seriesID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelSeriesScheduleHandler handles POST /series/{series_id}/schedule/cancel
|
||||||
|
// This is a moderator-only action that requires playoffs.manage permission.
|
||||||
|
func CancelSeriesScheduleHandler(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
err := db.CancelSeriesSchedule(ctx, tx, seriesID, reason, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Cancel", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.CancelSeriesSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Schedule Cancelled", "The series schedule has been cancelled.", nil)
|
||||||
|
respond.HXRedirect(w, "/series/%d", seriesID)
|
||||||
|
})
|
||||||
|
}
|
||||||
67
internal/handlers/team_detail.go
Normal file
67
internal/handlers/team_detail.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
|
teamsview "git.haelnorr.com/h/oslstats/internal/view/teamsview"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TeamDetailPage renders the global team detail page showing cross-season stats
|
||||||
|
func TeamDetailPage(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamIDStr := r.PathValue("team_id")
|
||||||
|
|
||||||
|
teamID, err := strconv.Atoi(teamIDStr)
|
||||||
|
if err != nil {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var team *db.Team
|
||||||
|
var seasonInfos []*db.TeamSeasonInfo
|
||||||
|
var playerStats []*db.TeamAllTimePlayerStats
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
team, err = db.GetTeam(ctx, tx, teamID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetTeam")
|
||||||
|
}
|
||||||
|
|
||||||
|
seasonInfos, err = db.GetTeamSeasonParticipation(ctx, tx, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetTeamSeasonParticipation")
|
||||||
|
}
|
||||||
|
|
||||||
|
playerStats, err = db.GetTeamAllTimePlayerStats(ctx, tx, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetTeamAllTimePlayerStats")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTab := r.URL.Query().Get("tab")
|
||||||
|
if activeTab != "seasons" && activeTab != "stats" {
|
||||||
|
activeTab = "seasons"
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSafely(teamsview.DetailPage(team, seasonInfos, playerStats, activeTab), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ const (
|
|||||||
FixturesCreate Permission = "fixtures.create"
|
FixturesCreate Permission = "fixtures.create"
|
||||||
FixturesDelete Permission = "fixtures.delete"
|
FixturesDelete Permission = "fixtures.delete"
|
||||||
|
|
||||||
|
// Playoffs permissions
|
||||||
|
PlayoffsManage Permission = "playoffs.manage"
|
||||||
|
|
||||||
// Free Agent permissions
|
// Free Agent permissions
|
||||||
FreeAgentsAdd Permission = "free_agents.add"
|
FreeAgentsAdd Permission = "free_agents.add"
|
||||||
FreeAgentsRemove Permission = "free_agents.remove"
|
FreeAgentsRemove Permission = "free_agents.remove"
|
||||||
|
|||||||
@@ -44,17 +44,21 @@ func addMiddleware(
|
|||||||
func devMode(cfg *config.Config) hws.Middleware {
|
func devMode(cfg *config.Config) hws.Middleware {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if cfg.Flags.DevMode {
|
if !cfg.Flags.DevMode && !cfg.Flags.Staging {
|
||||||
devInfo := contexts.DevInfo{
|
next.ServeHTTP(w, r)
|
||||||
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)
|
|
||||||
return
|
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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,6 +124,9 @@ func refreshToken(
|
|||||||
case "expired", "expiring":
|
case "expired", "expiring":
|
||||||
newtoken, err := discordAPI.RefreshToken(token.Convert())
|
newtoken, err := discordAPI.RefreshToken(token.Convert())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "invalid_grant") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
return false, errors.Wrap(err, "discordAPI.RefreshToken")
|
return false, errors.Wrap(err, "discordAPI.RefreshToken")
|
||||||
}
|
}
|
||||||
err = user.UpdateDiscordToken(ctx, tx, newtoken)
|
err = user.UpdateDiscordToken(ctx, tx, newtoken)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func addRoutes(
|
|||||||
{
|
{
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Method: hws.MethodGET,
|
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},
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
Handler: auth.LoginReq(handlers.Logout(s, auth, conn, discordAPI)),
|
Handler: auth.LoginReq(handlers.Logout(s, auth, conn, discordAPI)),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: "/profile",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: auth.LoginReq(handlers.ProfileRedirect(s)),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
seasonRoutes := []hws.Route{
|
seasonRoutes := []hws.Route{
|
||||||
@@ -132,6 +137,16 @@ func addRoutes(
|
|||||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
Handler: handlers.SeasonLeagueFinalsPage(s, conn),
|
Handler: handlers.SeasonLeagueFinalsPage(s, conn),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/finals/setup",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeasonLeagueFinalsSetupForm(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/finals/setup",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeasonLeagueFinalsSetupSubmit(s, conn)),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Path: "/seasons/{season_short_name}/add-league/{league_short_name}",
|
Path: "/seasons/{season_short_name}/add-league/{league_short_name}",
|
||||||
Method: hws.MethodPOST,
|
Method: hws.MethodPOST,
|
||||||
@@ -214,6 +229,27 @@ func addRoutes(
|
|||||||
Method: hws.MethodDELETE,
|
Method: hws.MethodDELETE,
|
||||||
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)),
|
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
|
// Fixture scheduling routes
|
||||||
{
|
{
|
||||||
Path: "/fixtures/{fixture_id}/schedule",
|
Path: "/fixtures/{fixture_id}/schedule",
|
||||||
@@ -287,6 +323,133 @@ func addRoutes(
|
|||||||
Method: hws.MethodPOST,
|
Method: hws.MethodPOST,
|
||||||
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.DiscardMatchResult(s, conn)),
|
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)),
|
||||||
|
},
|
||||||
|
// Series detail page routes
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: handlers.SeriesDetailPage(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/overview",
|
||||||
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
|
Handler: handlers.SeriesDetailOverviewPage(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/preview",
|
||||||
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
|
Handler: handlers.SeriesDetailPreviewPage(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/analysis",
|
||||||
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
|
Handler: handlers.SeriesDetailAnalysisPage(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/scheduling",
|
||||||
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
|
Handler: handlers.SeriesDetailSchedulePage(s, conn),
|
||||||
|
},
|
||||||
|
// Series scheduling routes
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/schedule",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.ProposeSeriesSchedule(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/schedule/{schedule_id}/accept",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.AcceptSeriesSchedule(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/schedule/{schedule_id}/reject",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.RejectSeriesSchedule(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/schedule/{schedule_id}/withdraw",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.WithdrawSeriesScheduleHandler(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/schedule/postpone",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.PostponeSeriesSchedule(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/schedule/reschedule",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.RescheduleSeriesHandler(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/schedule/cancel",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.CancelSeriesScheduleHandler(s, conn)),
|
||||||
|
},
|
||||||
|
// Series result management routes
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/results/upload",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesUploadResultPage(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/results/upload",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesUploadResults(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/results/review",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesReviewResults(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/results/finalize",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesFinalizeResults(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/series/{series_id}/results/discard",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesDiscardResults(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{
|
teamRoutes := []hws.Route{
|
||||||
@@ -310,6 +473,11 @@ func addRoutes(
|
|||||||
Method: hws.MethodPOST,
|
Method: hws.MethodPOST,
|
||||||
Handler: perms.RequirePermission(s, permissions.TeamsManagePlayers)(handlers.ManageTeamRoster(s, conn)),
|
Handler: perms.RequirePermission(s, permissions.TeamsManagePlayers)(handlers.ManageTeamRoster(s, conn)),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: "/teams/{team_id}",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: handlers.TeamDetailPage(s, conn),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
htmxRoutes := []hws.Route{
|
htmxRoutes := []hws.Route{
|
||||||
@@ -457,6 +625,7 @@ func addRoutes(
|
|||||||
routes = append(routes, leagueRoutes...)
|
routes = append(routes, leagueRoutes...)
|
||||||
routes = append(routes, fixturesRoutes...)
|
routes = append(routes, fixturesRoutes...)
|
||||||
routes = append(routes, teamRoutes...)
|
routes = append(routes, teamRoutes...)
|
||||||
|
routes = append(routes, playerRoutes...)
|
||||||
|
|
||||||
// Register the routes with the server
|
// Register the routes with the server
|
||||||
err := s.AddRoutes(routes...)
|
err := s.AddRoutes(routes...)
|
||||||
|
|||||||
@@ -24,19 +24,19 @@ templ RegisterFormForm(username string) {
|
|||||||
this.isChecking = false;
|
this.isChecking = false;
|
||||||
this.isUnique = false;
|
this.isUnique = false;
|
||||||
},
|
},
|
||||||
enableSubmit() {
|
enableSubmit() {
|
||||||
this.canSubmit = true;
|
this.canSubmit = true;
|
||||||
},
|
},
|
||||||
handleSubmit() {
|
handleSubmit() {
|
||||||
this.isSubmitting = true;
|
this.isSubmitting = true;
|
||||||
this.buttontext = 'Loading...';
|
this.buttontext = "Loading...";
|
||||||
// Set timeout for 10 seconds
|
// Set timeout for 10 seconds
|
||||||
this.submitTimeout = setTimeout(() => {
|
this.submitTimeout = setTimeout(() => {
|
||||||
this.isSubmitting = false;
|
this.isSubmitting = false;
|
||||||
this.buttontext = 'Register';
|
this.buttontext = "Register";
|
||||||
this.errorMessage = 'Request timed out. Please try again.';
|
this.errorMessage = "Request timed out. Please try again.";
|
||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -49,7 +49,7 @@ templ RegisterFormForm(username string) {
|
|||||||
type="text"
|
type="text"
|
||||||
id="username"
|
id="username"
|
||||||
name="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,
|
'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-overlay0 focus:border-blue': !isUnique && !errorMessage,
|
||||||
'border-green focus:border-green': isUnique && !isChecking && !errorMessage,
|
'border-green focus:border-green': isUnique && !isChecking && !errorMessage,
|
||||||
@@ -60,19 +60,18 @@ templ RegisterFormForm(username string) {
|
|||||||
value={ username }
|
value={ username }
|
||||||
@input="resetErr(); isEmpty = $el.value.trim() === ''; if(isEmpty) { errorMessage='Username is required'; isUnique=false; }"
|
@input="resetErr(); isEmpty = $el.value.trim() === ''; if(isEmpty) { errorMessage='Username is required'; isUnique=false; }"
|
||||||
hx-post="/htmx/isusernameunique"
|
hx-post="/htmx/isusernameunique"
|
||||||
hx-trigger="load delay:100ms, input changed delay:500ms"
|
hx-trigger="input changed delay:500ms"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
@htmx:before-request="if($el.value.trim() === '') { isEmpty=true; return; } isEmpty=false; isChecking=true; isUnique=false; errorMessage=''"
|
@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; }"
|
@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
|
||||||
<p
|
class="text-center text-xs text-red mt-2"
|
||||||
class="text-center text-xs text-red mt-2"
|
id="username-error"
|
||||||
id="username-error"
|
x-show="errorMessage && !isSubmitting"
|
||||||
x-show="errorMessage && !isSubmitting"
|
x-cloak
|
||||||
x-cloak
|
x-text="errorMessage"
|
||||||
x-text="errorMessage"
|
></p>
|
||||||
></p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func getFooterItems() []FooterItem {
|
|||||||
// Returns the template fragment for the Footer
|
// Returns the template fragment for the Footer
|
||||||
templ Footer() {
|
templ Footer() {
|
||||||
<footer class="bg-mantle mt-10">
|
<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()
|
@backToTopButton()
|
||||||
<div class="lg:flex lg:items-end lg:justify-between">
|
<div class="lg:flex lg:items-end lg:justify-between">
|
||||||
@footerBranding()
|
@footerBranding()
|
||||||
@@ -23,18 +23,26 @@ templ Footer() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="lg:flex lg:items-end lg:justify-between">
|
<div class="lg:flex lg:items-end lg:justify-between">
|
||||||
@footerCopyright()
|
@footerCopyright()
|
||||||
@themeSelector()
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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() {
|
templ backToTopButton() {
|
||||||
<div class="absolute end-4 top-4 sm:end-6 lg:end-8">
|
<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
|
class="inline-block rounded-full bg-teal p-2 text-crust
|
||||||
shadow-sm transition hover:bg-teal/75"
|
shadow-sm transition hover:bg-teal/75 hover:cursor-pointer"
|
||||||
href="#main-content"
|
onclick="document.getElementById('page-viewport').scrollTo({ top: 0, behavior: 'smooth' })"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Back to top</span>
|
<span class="sr-only">Back to top</span>
|
||||||
<svg
|
<svg
|
||||||
@@ -51,18 +59,15 @@ templ backToTopButton() {
|
|||||||
clip-rule="evenodd"
|
clip-rule="evenodd"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ footerBranding() {
|
templ footerBranding() {
|
||||||
<div>
|
<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>
|
<span class="text-2xl">OSL Stats</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mx-auto max-w-md text-center leading-relaxed text-subtext0">
|
|
||||||
placeholder text
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,42 +92,7 @@ templ footerLinks(items []FooterItem) {
|
|||||||
templ footerCopyright() {
|
templ footerCopyright() {
|
||||||
<div>
|
<div>
|
||||||
<p class="mt-4 text-center text-sm text-overlay0">
|
<p class="mt-4 text-center text-sm text-overlay0">
|
||||||
by Haelnorr | placeholder text
|
by Haelnorr
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,13 +11,8 @@ templ Layout(title string) {
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html
|
<html
|
||||||
lang="en"
|
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>
|
<head>
|
||||||
<script src="/static/js/theme.js"></script>
|
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>{ title }</title>
|
<title>{ title }</title>
|
||||||
@@ -34,7 +29,7 @@ templ Layout(title string) {
|
|||||||
}
|
}
|
||||||
</head>
|
</head>
|
||||||
<body
|
<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"
|
hx-ext="ws"
|
||||||
ws-connect={ devInfo.WebsocketBase + "/ws/notifications" }
|
ws-connect={ devInfo.WebsocketBase + "/ws/notifications" }
|
||||||
>
|
>
|
||||||
@@ -43,21 +38,34 @@ templ Layout(title string) {
|
|||||||
@popup.ConfirmModal()
|
@popup.ConfirmModal()
|
||||||
<div
|
<div
|
||||||
id="main-content"
|
id="main-content"
|
||||||
class="flex flex-col h-screen justify-between"
|
class="flex flex-col h-screen"
|
||||||
>
|
>
|
||||||
|
if devInfo.StagingBanner {
|
||||||
|
@stagingBanner()
|
||||||
|
}
|
||||||
@Navbar()
|
@Navbar()
|
||||||
if previewRole != nil {
|
if previewRole != nil {
|
||||||
@previewModeBanner(previewRole)
|
@previewModeBanner(previewRole)
|
||||||
}
|
}
|
||||||
<div id="page-content" class="mb-auto md:px-5 md:pt-5">
|
<div id="page-viewport" class="flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
{ children... }
|
<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>
|
</div>
|
||||||
@Footer()
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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)
|
// Preview mode banner (private helper)
|
||||||
templ previewModeBanner(previewRole *db.Role) {
|
templ previewModeBanner(previewRole *db.Role) {
|
||||||
<div class="bg-yellow/20 border-b border-yellow/40 px-4 py-3">
|
<div class="bg-yellow/20 border-b border-yellow/40 px-4 py-3">
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ templ mobileNav(navItems []NavItem, user *db.User) {
|
|||||||
<div
|
<div
|
||||||
x-show="open"
|
x-show="open"
|
||||||
x-transition
|
x-transition
|
||||||
|
@click.outside="open = false"
|
||||||
class="absolute w-full bg-mantle sm:hidden z-10"
|
class="absolute w-full bg-mantle sm:hidden z-10"
|
||||||
>
|
>
|
||||||
<div class="px-4 py-6">
|
<div class="px-4 py-6">
|
||||||
|
|||||||
54
internal/view/component/links/links.templ
Normal file
54
internal/view/component/links/links.templ
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package links
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// PlayerLink renders a player name as a clickable link to their profile page.
|
||||||
|
// The player's DisplayName() is used as the link text.
|
||||||
|
templ PlayerLink(player *db.Player) {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/players/%d", player.ID)) }
|
||||||
|
class="text-text hover:text-blue transition"
|
||||||
|
>
|
||||||
|
{ player.DisplayName() }
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerLinkFromStats renders a player name link using a player ID and name string.
|
||||||
|
// This is useful when only aggregated stats are available (no full Player object).
|
||||||
|
templ PlayerLinkFromStats(playerID int, playerName string) {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/players/%d", playerID)) }
|
||||||
|
class="text-text hover:text-blue transition"
|
||||||
|
>
|
||||||
|
{ playerName }
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeamLinkInSeason renders a team name as a clickable link to the team's
|
||||||
|
// season-specific detail page, with an optional color dot prefix.
|
||||||
|
templ TeamLinkInSeason(team *db.Team, season *db.Season, league *db.League) {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams/%d", season.ShortName, league.ShortName, team.ID)) }
|
||||||
|
class="flex items-center gap-2 hover:text-blue transition"
|
||||||
|
>
|
||||||
|
if team.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<span class="text-sm font-medium">{ team.Name }</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeamNameLinkInSeason renders just the team name as a clickable link (no color dot).
|
||||||
|
// Useful where the color dot is already rendered separately or in inline contexts.
|
||||||
|
templ TeamNameLinkInSeason(team *db.Team, season *db.Season, league *db.League) {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams/%d", season.ShortName, league.ShortName, team.ID)) }
|
||||||
|
class="hover:text-blue transition"
|
||||||
|
>
|
||||||
|
{ team.Name }
|
||||||
|
</a>
|
||||||
|
}
|
||||||
33
internal/view/homeview/external_links.templ
Normal file
33
internal/view/homeview/external_links.templ
Normal 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>
|
||||||
|
}
|
||||||
@@ -1,13 +1,32 @@
|
|||||||
package homeview
|
package homeview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
|
|
||||||
// Page content for the index page
|
// Page content for the index page
|
||||||
templ IndexPage() {
|
templ IndexPage(season *db.Season, standings []LeagueStandings) {
|
||||||
@baseview.Layout("OSL Stats") {
|
@baseview.Layout("Oceanic Slapshot League") {
|
||||||
<div class="text-center mt-25">
|
<div class="max-w-screen-2xl mx-auto px-2">
|
||||||
<div class="text-4xl lg:text-6xl">OSL Stats</div>
|
<div class="mt-8 mb-12">
|
||||||
<div>Placeholder text</div>
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
151
internal/view/homeview/latest_standings.templ
Normal file
151
internal/view/homeview/latest_standings.templ
Normal 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>
|
||||||
|
}
|
||||||
97
internal/view/playersview/player_page.templ
Normal file
97
internal/view/playersview/player_page.templ
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package playersview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ PlayerLayout(activeSection string, player *db.Player, isOwner bool) {
|
||||||
|
@baseview.Layout(player.DisplayName() + " - Player Profile") {
|
||||||
|
<div class="max-w-screen-2xl mx-auto px-4 py-8">
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h1 class="text-4xl font-bold text-text">{ player.DisplayName() }</h1>
|
||||||
|
if isOwner {
|
||||||
|
<span class="px-2 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium">
|
||||||
|
Your Profile
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
if player.SlapID != nil {
|
||||||
|
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||||||
|
Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- SlapID Link Prompt (if needed) -->
|
||||||
|
if player.SlapID == nil && isOwner {
|
||||||
|
<div class="px-6 pt-6">
|
||||||
|
@SlapIDSection(player, isOwner)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<nav class="bg-surface0 border-b border-surface1" data-tab-nav="player-content">
|
||||||
|
<ul class="flex flex-wrap">
|
||||||
|
@playerNavItem("stats", "Stats", activeSection, player)
|
||||||
|
@playerNavItem("teams", "Teams", activeSection, player)
|
||||||
|
@playerNavItem("seasons", "Seasons", activeSection, player)
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<!-- Content Area -->
|
||||||
|
<main class="bg-crust p-6" id="player-content">
|
||||||
|
{ children... }
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/tabs.js" defer></script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ playerNavItem(section string, label string, activeSection string, player *db.Player) {
|
||||||
|
{{
|
||||||
|
isActive := section == activeSection
|
||||||
|
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
|
||||||
|
activeClasses := "border-blue text-blue font-semibold"
|
||||||
|
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
||||||
|
url := fmt.Sprintf("/players/%d/%s", player.ID, section)
|
||||||
|
}}
|
||||||
|
<li class="inline-block">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(url) }
|
||||||
|
hx-post={ url }
|
||||||
|
hx-target="#player-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-push-url={ url }
|
||||||
|
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||||||
|
>
|
||||||
|
{ label }
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full page wrappers (for GET requests / direct navigation)
|
||||||
|
|
||||||
|
templ PlayerStatsPage(player *db.Player, isOwner bool, stats *db.PlayerAllTimeStats, seasons []*db.Season, teams []*db.Team) {
|
||||||
|
@PlayerLayout("stats", player, isOwner) {
|
||||||
|
@PlayerStatsTab(player, stats, seasons, teams, "", 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ PlayerTeamsPage(player *db.Player, isOwner bool, teamInfos []*db.PlayerTeamInfo) {
|
||||||
|
@PlayerLayout("teams", player, isOwner) {
|
||||||
|
@PlayerTeamsTab(teamInfos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ PlayerSeasonsPage(player *db.Player, isOwner bool, seasonInfos []*db.PlayerSeasonInfo) {
|
||||||
|
@PlayerLayout("seasons", player, isOwner) {
|
||||||
|
@PlayerSeasonsTab(seasonInfos)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
internal/view/playersview/player_seasons_tab.templ
Normal file
73
internal/view/playersview/player_seasons_tab.templ
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package playersview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ PlayerSeasonsTab(seasonInfos []*db.PlayerSeasonInfo) {
|
||||||
|
if len(seasonInfos) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No season history yet.</p>
|
||||||
|
<p class="text-subtext1 text-sm mt-2">This player has not participated in any seasons.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-mantle border-b border-surface1">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Season</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-text">League</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Team</th>
|
||||||
|
<th class="px-4 py-3 text-center text-sm font-semibold text-text">Role</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface1">
|
||||||
|
for _, info := range seasonInfos {
|
||||||
|
<tr class="hover:bg-surface1 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s", info.Season.ShortName)) }
|
||||||
|
class="text-blue hover:text-blue/80 transition"
|
||||||
|
>
|
||||||
|
{ info.Season.Name }
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-subtext0">
|
||||||
|
{ info.League.Name }
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf(
|
||||||
|
"/seasons/%s/leagues/%s/teams/%d",
|
||||||
|
info.Season.ShortName, info.League.ShortName, info.Team.ID,
|
||||||
|
)) }
|
||||||
|
class="text-blue hover:text-blue/80 transition"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
if info.Team.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-3 h-3 rounded-full border border-surface1 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(info.Team.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<span>{ info.Team.Name }</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-center">
|
||||||
|
if info.IsManager {
|
||||||
|
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||||
|
Manager
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-subtext1 text-xs">Player</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
130
internal/view/playersview/player_stats_tab.templ
Normal file
130
internal/view/playersview/player_stats_tab.templ
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package playersview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ PlayerStatsTab(player *db.Player, stats *db.PlayerAllTimeStats, seasons []*db.Season, teams []*db.Team, activeFilter string, activeFilterID int) {
|
||||||
|
<div class="space-y-6" data-filter-url={ fmt.Sprintf("/players/%d/stats/filter", player.ID) }>
|
||||||
|
<!-- Filter Controls -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
|
<!-- Season Filter -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs text-subtext0 uppercase font-medium mb-1">Filter by Season</label>
|
||||||
|
<select
|
||||||
|
name="season_id"
|
||||||
|
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||||
|
onchange={ handleFilterChange("season") }
|
||||||
|
>
|
||||||
|
<option value="">All Seasons</option>
|
||||||
|
for _, s := range seasons {
|
||||||
|
<option
|
||||||
|
value={ fmt.Sprint(s.ID) }
|
||||||
|
selected?={ activeFilter == "season" && activeFilterID == s.ID }
|
||||||
|
>
|
||||||
|
{ s.Name }
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Team Filter -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs text-subtext0 uppercase font-medium mb-1">Filter by Team</label>
|
||||||
|
<select
|
||||||
|
name="team_id"
|
||||||
|
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||||
|
onchange={ handleFilterChange("team") }
|
||||||
|
>
|
||||||
|
<option value="">All Teams</option>
|
||||||
|
for _, t := range teams {
|
||||||
|
<option
|
||||||
|
value={ fmt.Sprint(t.ID) }
|
||||||
|
selected?={ activeFilter == "team" && activeFilterID == t.ID }
|
||||||
|
>
|
||||||
|
{ t.Name }
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Filter Label -->
|
||||||
|
<div class="text-sm text-subtext0">
|
||||||
|
if activeFilter == "" {
|
||||||
|
Showing <span class="text-text font-medium">All-Time</span> stats
|
||||||
|
} else if activeFilter == "season" {
|
||||||
|
Showing stats for season:
|
||||||
|
<span class="text-text font-medium">
|
||||||
|
{ getSeasonName(seasons, activeFilterID) }
|
||||||
|
</span>
|
||||||
|
} else if activeFilter == "team" {
|
||||||
|
Showing stats for team:
|
||||||
|
<span class="text-text font-medium">
|
||||||
|
{ getTeamName(teams, activeFilterID) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
@playerStatsGrid(stats)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ playerStatsGrid(stats *db.PlayerAllTimeStats) {
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
@statCard("Games Played", fmt.Sprint(stats.GamesPlayed), "text-blue")
|
||||||
|
@statCard("Goals", fmt.Sprint(stats.Goals), "text-green")
|
||||||
|
@statCard("Assists", fmt.Sprint(stats.Assists), "text-teal")
|
||||||
|
@statCard("Saves", fmt.Sprint(stats.Saves), "text-yellow")
|
||||||
|
@statCard("Shots", fmt.Sprint(stats.Shots), "text-peach")
|
||||||
|
@statCard("Blocks", fmt.Sprint(stats.Blocks), "text-mauve")
|
||||||
|
@statCard("Passes", fmt.Sprint(stats.Passes), "text-sky")
|
||||||
|
@statCard("Periods Played", fmt.Sprint(stats.PeriodsPlayed), "text-subtext0")
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ statCard(label string, value string, colorClass string) {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-4 text-center">
|
||||||
|
<p class="text-xs text-subtext0 uppercase font-medium mb-1">{ label }</p>
|
||||||
|
<p class={ "text-2xl font-bold", colorClass }>{ value }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
script handleFilterChange(filterType string) {
|
||||||
|
var container = event.target.closest("[data-filter-url]")
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
var baseUrl = container.getAttribute("data-filter-url")
|
||||||
|
var seasonSelect = container.querySelector("select[name='season_id']")
|
||||||
|
var teamSelect = container.querySelector("select[name='team_id']")
|
||||||
|
|
||||||
|
// Reset the other filter when one is selected
|
||||||
|
if (filterType === "season" && teamSelect) {
|
||||||
|
teamSelect.value = ""
|
||||||
|
} else if (filterType === "team" && seasonSelect) {
|
||||||
|
seasonSelect.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = event.target.value
|
||||||
|
var url = baseUrl
|
||||||
|
if (value) {
|
||||||
|
url += "?filter=" + filterType + "&filter_id=" + value
|
||||||
|
}
|
||||||
|
|
||||||
|
htmx.ajax("POST", url, {target: "#player-content", swap: "innerHTML"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSeasonName(seasons []*db.Season, id int) string {
|
||||||
|
for _, s := range seasons {
|
||||||
|
if s.ID == id {
|
||||||
|
return s.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTeamName(teams []*db.Team, id int) string {
|
||||||
|
for _, t := range teams {
|
||||||
|
if t.ID == id {
|
||||||
|
return t.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
51
internal/view/playersview/player_teams_tab.templ
Normal file
51
internal/view/playersview/player_teams_tab.templ
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package playersview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ PlayerTeamsTab(teamInfos []*db.PlayerTeamInfo) {
|
||||||
|
if len(teamInfos) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No team history yet.</p>
|
||||||
|
<p class="text-subtext1 text-sm mt-2">This player has not been on any teams.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-mantle border-b border-surface1">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Team</th>
|
||||||
|
<th class="px-4 py-3 text-right text-sm font-semibold text-text">Seasons Played</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface1">
|
||||||
|
for _, info := range teamInfos {
|
||||||
|
<tr class="hover:bg-surface1 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/teams/%d", info.Team.ID)) }
|
||||||
|
class="text-blue hover:text-blue/80 transition"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
if info.Team.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 rounded-full border border-surface1 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(info.Team.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<span>{ info.Team.Name }</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-subtext0 text-right">
|
||||||
|
{ fmt.Sprint(info.SeasonsCount) }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/view/playersview/slap_id_section.templ
Normal file
52
internal/view/playersview/slap_id_section.templ
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package playersview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ SlapIDSection(player *db.Player, isOwner bool) {
|
||||||
|
<div id="slap-id-section">
|
||||||
|
if player.SlapID == nil && isOwner {
|
||||||
|
@slapIDLinkPrompt(player)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ slapIDLinkPrompt(player *db.Player) {
|
||||||
|
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-6">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<svg class="w-6 h-6 text-yellow shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-yellow mb-2">Slapshot ID Not Linked</h3>
|
||||||
|
<p class="text-subtext0 mb-4">
|
||||||
|
Your Slapshot ID is not linked. Please link your Steam account to your Discord account, then click the button below to connect your Slapshot ID.
|
||||||
|
</p>
|
||||||
|
<p class="text-subtext1 text-sm mb-4">
|
||||||
|
Need help linking Steam to Discord?
|
||||||
|
<a
|
||||||
|
href="https://support.discord.com/hc/en-us/articles/32330173689623-Account-Connections-on-Discord-FAQ#h_01JVZBVNC1HYWX4BTPFN9B4B1V"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-blue hover:text-blue/80 underline transition"
|
||||||
|
>
|
||||||
|
Follow this guide
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
hx-post={ fmt.Sprintf("/players/%d/link-slapid", player.ID) }
|
||||||
|
hx-target="#slap-id-section"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Link Slapshot ID
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
280
internal/view/seasonsview/finals_setup_form.templ
Normal file
280
internal/view/seasonsview/finals_setup_form.templ
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/datepicker"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ FinalsSetupForm(
|
||||||
|
season *db.Season,
|
||||||
|
league *db.League,
|
||||||
|
leaderboard []*db.LeaderboardEntry,
|
||||||
|
unplayedFixtures []*db.Fixture,
|
||||||
|
) {
|
||||||
|
{{
|
||||||
|
// Determine the recommended format value for the default Alpine state
|
||||||
|
defaultFormat := ""
|
||||||
|
if len(leaderboard) >= 10 && len(leaderboard) <= 15 {
|
||||||
|
defaultFormat = string(db.PlayoffFormat10to15)
|
||||||
|
} else if len(leaderboard) >= 7 && len(leaderboard) <= 9 {
|
||||||
|
defaultFormat = string(db.PlayoffFormat7to9)
|
||||||
|
} else if len(leaderboard) >= 5 && len(leaderboard) <= 6 {
|
||||||
|
defaultFormat = string(db.PlayoffFormat5to6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefill dates from existing season values
|
||||||
|
endDateDefault := ""
|
||||||
|
if !season.EndDate.IsZero() {
|
||||||
|
endDateDefault = season.EndDate.Time.Format("02/01/2006")
|
||||||
|
}
|
||||||
|
finalsStartDefault := ""
|
||||||
|
if !season.FinalsStartDate.IsZero() {
|
||||||
|
finalsStartDefault = season.FinalsStartDate.Time.Format("02/01/2006")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div
|
||||||
|
class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"
|
||||||
|
x-data={ fmt.Sprintf("{ selectedFormat: '%s' }", defaultFormat) }
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-mantle border-b border-surface1 px-6 py-4">
|
||||||
|
<h2 class="text-xl font-bold text-text flex items-center gap-2">
|
||||||
|
<span class="text-yellow">★</span>
|
||||||
|
Begin Finals Setup
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-subtext0 mt-1">
|
||||||
|
Configure playoff format and dates for { league.Name }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/seasons/%s/leagues/%s/finals/setup", season.ShortName, league.ShortName) }
|
||||||
|
hx-swap="none"
|
||||||
|
class="p-6 space-y-6"
|
||||||
|
>
|
||||||
|
<!-- Date Fields -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
@datepicker.DatePickerWithDefault(
|
||||||
|
"regular_season_end_date",
|
||||||
|
"regular_season_end_date",
|
||||||
|
"Regular Season End Date",
|
||||||
|
"DD/MM/YYYY",
|
||||||
|
true,
|
||||||
|
"",
|
||||||
|
endDateDefault,
|
||||||
|
)
|
||||||
|
<p class="text-xs text-subtext0 mt-1">Last day of the regular season (inclusive)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
@datepicker.DatePickerWithDefault(
|
||||||
|
"finals_start_date",
|
||||||
|
"finals_start_date",
|
||||||
|
"Finals Start Date",
|
||||||
|
"DD/MM/YYYY",
|
||||||
|
true,
|
||||||
|
"",
|
||||||
|
finalsStartDefault,
|
||||||
|
)
|
||||||
|
<p class="text-xs text-subtext0 mt-1">First playoff matches begin on this date</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Format Selection -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-3">Playoff Format</label>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@formatOption(
|
||||||
|
string(db.PlayoffFormat5to6),
|
||||||
|
"5-6 Teams",
|
||||||
|
"Top 5 qualify. 1st earns a bye, 2nd vs 3rd (upper), 4th vs 5th (lower). Double-chance for top seeds.",
|
||||||
|
len(leaderboard) >= 5 && len(leaderboard) <= 6,
|
||||||
|
len(leaderboard),
|
||||||
|
)
|
||||||
|
@formatOption(
|
||||||
|
string(db.PlayoffFormat7to9),
|
||||||
|
"7-9 Teams",
|
||||||
|
"Top 6 qualify. 1st & 2nd placed into semis. 3rd vs 6th and 4th vs 5th in quarter finals.",
|
||||||
|
len(leaderboard) >= 7 && len(leaderboard) <= 9,
|
||||||
|
len(leaderboard),
|
||||||
|
)
|
||||||
|
@formatOption(
|
||||||
|
string(db.PlayoffFormat10to15),
|
||||||
|
"10-15 Teams",
|
||||||
|
"Top 8 qualify. Top 4 get a second chance in qualifying finals.",
|
||||||
|
len(leaderboard) >= 10 && len(leaderboard) <= 15,
|
||||||
|
len(leaderboard),
|
||||||
|
)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Per-Round Best-of-N Configuration -->
|
||||||
|
<div x-show="selectedFormat !== ''" x-cloak>
|
||||||
|
<label class="block text-sm font-medium mb-3">Series Format (Best-of-N per Round)</label>
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg p-4 space-y-3">
|
||||||
|
<!-- 5-6 Teams rounds -->
|
||||||
|
<template x-if={ fmt.Sprintf("selectedFormat === '%s'", string(db.PlayoffFormat5to6)) }>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@boRoundDropdown("bo_upper_bracket", "Upper Bracket", "2nd vs 3rd")
|
||||||
|
@boRoundDropdown("bo_lower_bracket", "Lower Bracket", "4th vs 5th (elimination)")
|
||||||
|
@boRoundDropdown("bo_upper_final", "Upper Final", "1st vs Winner of Upper Bracket")
|
||||||
|
@boRoundDropdown("bo_lower_final", "Lower Final", "Loser of Upper Final vs Winner of Lower Bracket")
|
||||||
|
@boRoundDropdown("bo_grand_final", "Grand Final", "Winner of Upper Final vs Winner of Lower Final")
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 7-9 Teams rounds -->
|
||||||
|
<template x-if={ fmt.Sprintf("selectedFormat === '%s'", string(db.PlayoffFormat7to9)) }>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@boRoundDropdown("bo_quarter_final", "Quarter Finals", "3rd vs 6th, 4th vs 5th")
|
||||||
|
@boRoundDropdown("bo_semi_final", "Semi Finals", "1st vs QF winner, 2nd vs QF winner")
|
||||||
|
@boRoundDropdown("bo_third_place", "Third Place Playoff", "SF1 loser vs SF2 loser")
|
||||||
|
@boRoundDropdown("bo_grand_final", "Grand Final", "SF1 winner vs SF2 winner")
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 10-15 Teams rounds -->
|
||||||
|
<template x-if={ fmt.Sprintf("selectedFormat === '%s'", string(db.PlayoffFormat10to15)) }>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@boRoundDropdown("bo_qualifying_final", "Qualifying Finals", "1st vs 4th, 2nd vs 3rd (losers get second chance)")
|
||||||
|
@boRoundDropdown("bo_elimination_final", "Elimination Finals", "5th vs 8th, 6th vs 7th (losers eliminated)")
|
||||||
|
@boRoundDropdown("bo_semi_final", "Semi Finals", "QF losers vs EF winners")
|
||||||
|
@boRoundDropdown("bo_preliminary_final", "Preliminary Finals", "QF winners vs SF winners")
|
||||||
|
@boRoundDropdown("bo_third_place", "Third Place Playoff", "PF1 loser vs PF2 loser")
|
||||||
|
@boRoundDropdown("bo_grand_final", "Grand Final", "PF1 winner vs PF2 winner")
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Unplayed Fixtures Warning -->
|
||||||
|
if len(unplayedFixtures) > 0 {
|
||||||
|
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<svg class="w-5 h-5 text-yellow mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<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>
|
||||||
|
<p class="text-sm font-semibold text-yellow mb-1">
|
||||||
|
{ fmt.Sprintf("%d unplayed fixture(s) found", len(unplayedFixtures)) }
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-subtext0 mb-3">
|
||||||
|
These fixtures will be recorded as a mutual forfeit when you begin finals.
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div class="max-h-40 overflow-y-auto space-y-1">
|
||||||
|
for _, fixture := range unplayedFixtures {
|
||||||
|
<div class="text-xs text-subtext1 flex items-center gap-2">
|
||||||
|
<span class="text-subtext0">GW{ fmt.Sprint(*fixture.GameWeek) }</span>
|
||||||
|
<span>{ fixture.HomeTeam.Name }</span>
|
||||||
|
<span class="text-subtext0">vs</span>
|
||||||
|
<span>{ fixture.AwayTeam.Name }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Standings Preview -->
|
||||||
|
if len(leaderboard) > 0 {
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-3">Current Standings</label>
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-surface0 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">Team</th>
|
||||||
|
<th class="px-3 py-2 text-center text-xs font-semibold text-text">GP</th>
|
||||||
|
<th class="px-3 py-2 text-center text-xs font-semibold text-blue">PTS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface1">
|
||||||
|
for _, entry := range leaderboard {
|
||||||
|
@standingsPreviewRow(entry, season, league)
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end gap-3 pt-4 border-t border-surface1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
hx-post={ fmt.Sprintf("/seasons/%s/leagues/%s/finals", season.ShortName, league.ShortName) }
|
||||||
|
hx-target="#finals-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="px-4 py-2 bg-surface1 hover:bg-surface2 text-text rounded-lg font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-6 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg font-semibold transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Begin Finals
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ formatOption(value, label, description string, recommended bool, teamCount int) {
|
||||||
|
<label
|
||||||
|
class="flex items-start gap-3 p-3 bg-mantle border border-surface1 rounded-lg hover:bg-surface0 transition hover:cursor-pointer"
|
||||||
|
x-bind:class={ fmt.Sprintf("selectedFormat === '%s' && 'border-blue/50 bg-blue/5'", value) }
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="format"
|
||||||
|
value={ value }
|
||||||
|
if recommended {
|
||||||
|
checked
|
||||||
|
}
|
||||||
|
x-model="selectedFormat"
|
||||||
|
class="mt-1 text-blue focus:ring-blue hover:cursor-pointer"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-text">{ label }</span>
|
||||||
|
if recommended {
|
||||||
|
<span class="ml-2 px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
||||||
|
Recommended for { fmt.Sprint(teamCount) } teams
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<p class="text-xs text-subtext0 mt-0.5">{ description }</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ boRoundDropdown(name, label, description string) {
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="text-sm font-medium text-text">{ label }</span>
|
||||||
|
<p class="text-xs text-subtext0 truncate">{ description }</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
name={ name }
|
||||||
|
class="w-24 px-3 py-1.5 bg-surface0 border border-surface1 rounded text-sm text-text focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="1" selected>BO1</option>
|
||||||
|
<option value="2">BO3</option>
|
||||||
|
<option value="3">BO5</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ standingsPreviewRow(entry *db.LeaderboardEntry, season *db.Season, league *db.League) {
|
||||||
|
<tr class="hover:bg-surface0/50 transition-colors">
|
||||||
|
<td class="px-3 py-2 text-center text-sm font-medium text-subtext0">
|
||||||
|
{ fmt.Sprint(entry.Position) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
@links.TeamLinkInSeason(entry.Team, season, league)
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-center text-sm text-subtext0">
|
||||||
|
{ fmt.Sprint(entry.Record.Played) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-center text-sm font-semibold text-blue">
|
||||||
|
{ fmt.Sprint(entry.Record.Points) }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
@@ -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/permissions"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
import "sort"
|
import "sort"
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
templ FixtureDetailPage(
|
// FixtureDetailLayout renders the fixture detail page layout with header and
|
||||||
fixture *db.Fixture,
|
// tab navigation. Tab content is rendered as children.
|
||||||
currentSchedule *db.FixtureSchedule,
|
templ FixtureDetailLayout(activeTab string, fixture *db.Fixture, result *db.FixtureResult) {
|
||||||
history []*db.FixtureSchedule,
|
|
||||||
canSchedule bool,
|
|
||||||
userTeamID int,
|
|
||||||
result *db.FixtureResult,
|
|
||||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
|
||||||
activeTab string,
|
|
||||||
nominatedFreeAgents []*db.FixtureFreeAgent,
|
|
||||||
availableFreeAgents []*db.SeasonLeagueFreeAgent,
|
|
||||||
) {
|
|
||||||
{{
|
{{
|
||||||
permCache := contexts.Permissions(ctx)
|
|
||||||
canManage := permCache.HasPermission(permissions.FixturesManage)
|
|
||||||
backURL := fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", fixture.Season.ShortName, fixture.League.ShortName)
|
backURL := fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", fixture.Season.ShortName, fixture.League.ShortName)
|
||||||
isFinalized := result != nil && result.Finalized
|
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)) {
|
@baseview.Layout(fmt.Sprintf("%s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
|
||||||
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
||||||
@@ -70,23 +53,25 @@ templ FixtureDetailPage(
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tab Navigation (hidden when only one tab) -->
|
<!-- Tab Navigation -->
|
||||||
if !isFinalized {
|
<nav class="bg-surface0 border-b border-surface1" data-tab-nav="fixture-detail-content">
|
||||||
<nav class="bg-surface0 border-b border-surface1">
|
<ul class="flex flex-wrap">
|
||||||
<ul class="flex flex-wrap">
|
@fixtureTabItem("overview", "Overview", activeTab, fixture)
|
||||||
@fixtureTabItem("overview", "Overview", activeTab, fixture)
|
if isFinalized {
|
||||||
@fixtureTabItem("schedule", "Schedule", activeTab, fixture)
|
@fixtureTabItem("analysis", "Match Analysis", activeTab, fixture)
|
||||||
</ul>
|
} else {
|
||||||
</nav>
|
@fixtureTabItem("preview", "Match Preview", activeTab, fixture)
|
||||||
}
|
@fixtureTabItem("scheduling", "Schedule", activeTab, fixture)
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tab Content -->
|
<!-- Content Area -->
|
||||||
if activeTab == "overview" {
|
<main id="fixture-detail-content">
|
||||||
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents)
|
{ children... }
|
||||||
} else if activeTab == "schedule" {
|
</main>
|
||||||
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
|
|
||||||
}
|
|
||||||
</div>
|
</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"
|
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
|
||||||
activeClasses := "border-blue text-blue font-semibold"
|
activeClasses := "border-blue text-blue font-semibold"
|
||||||
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
||||||
url := fmt.Sprintf("/fixtures/%d", fixture.ID)
|
url := fmt.Sprintf("/fixtures/%d/%s", fixture.ID, section)
|
||||||
if section != "overview" {
|
|
||||||
url = fmt.Sprintf("/fixtures/%d?tab=%s", fixture.ID, section)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
<li class="inline-block">
|
<li class="inline-block">
|
||||||
<a
|
<a
|
||||||
href={ templ.SafeURL(url) }
|
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) }
|
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||||||
>
|
>
|
||||||
{ label }
|
{ label }
|
||||||
@@ -111,6 +97,107 @@ templ fixtureTabItem(section string, label string, activeTab string, fixture *db
|
|||||||
</li>
|
</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 ====================
|
// ==================== Overview Tab ====================
|
||||||
templ fixtureOverviewTab(
|
templ fixtureOverviewTab(
|
||||||
fixture *db.Fixture,
|
fixture *db.Fixture,
|
||||||
@@ -147,8 +234,8 @@ templ fixtureOverviewTab(
|
|||||||
}
|
}
|
||||||
<!-- Team Rosters -->
|
<!-- Team Rosters -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result)
|
@fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result, fixture.Season, fixture.League)
|
||||||
@fixtureTeamSection(fixture.AwayTeam, rosters["away"], "away", result)
|
@fixtureTeamSection(fixture.AwayTeam, rosters["away"], "away", result, fixture.Season, fixture.League)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -220,11 +307,28 @@ templ fixtureResultDisplay(fixture *db.Fixture, result *db.FixtureResult) {
|
|||||||
isOT := strings.EqualFold(result.EndReason, "Overtime")
|
isOT := strings.EqualFold(result.EndReason, "Overtime")
|
||||||
homeWon := result.Winner == "home"
|
homeWon := result.Winner == "home"
|
||||||
awayWon := result.Winner == "away"
|
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-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-text">Match Result</h2>
|
<h2 class="text-lg font-bold text-text">Match Result</h2>
|
||||||
<div class="flex items-center gap-2">
|
<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 {
|
if result.Finalized {
|
||||||
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
||||||
Finalized
|
Finalized
|
||||||
@@ -263,75 +367,332 @@ templ fixtureResultDisplay(fixture *db.Fixture, result *db.FixtureResult) {
|
|||||||
<p class="text-red/80 text-xs">{ *result.TamperingReason }</p>
|
<p class="text-red/80 text-xs">{ *result.TamperingReason }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Score Display -->
|
if isForfeit {
|
||||||
<div class="flex items-center justify-center gap-6 py-4">
|
<!-- Forfeit Display -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex flex-col items-center py-4 space-y-4">
|
||||||
if homeWon {
|
if isMutualForfeit {
|
||||||
<span class="text-2xl">🏆</span>
|
<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">🏆</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">🏆</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-subtext0">
|
||||||
|
{ forfeitTeamName } forfeited the match
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
if fixture.HomeTeam.Color != "" {
|
if result.ForfeitReason != nil && *result.ForfeitReason != "" {
|
||||||
<span
|
<div class="bg-surface0 border border-surface1 rounded-lg p-3 max-w-md w-full">
|
||||||
class="px-2.5 py-1 rounded text-sm font-bold"
|
<p class="text-xs text-subtext1 font-medium mb-1">Reason</p>
|
||||||
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
<p class="text-sm text-subtext0">{ *result.ForfeitReason }</p>
|
||||||
fixture.HomeTeam.Color, fixture.HomeTeam.Color, fixture.HomeTeam.Color) }
|
</div>
|
||||||
>
|
|
||||||
{ 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>
|
||||||
<div class="flex items-center gap-3">
|
} else {
|
||||||
<span class="text-4xl font-bold text-text">{ fmt.Sprint(result.AwayScore) }</span>
|
<!-- Normal Score Display -->
|
||||||
if fixture.AwayTeam.Color != "" {
|
<div class="flex items-center justify-center gap-6 py-4">
|
||||||
<span
|
<div class="flex items-center gap-3">
|
||||||
class="px-2.5 py-1 rounded text-sm font-bold"
|
if homeWon {
|
||||||
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
<span class="text-2xl">🏆</span>
|
||||||
fixture.AwayTeam.Color, fixture.AwayTeam.Color, fixture.AwayTeam.Color) }
|
}
|
||||||
>
|
if fixture.HomeTeam.Color != "" {
|
||||||
{ fixture.AwayTeam.ShortName }
|
<span
|
||||||
</span>
|
class="px-2.5 py-1 rounded text-sm font-bold"
|
||||||
} else {
|
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
||||||
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
fixture.HomeTeam.Color, fixture.HomeTeam.Color, fixture.HomeTeam.Color) }
|
||||||
{ fixture.AwayTeam.ShortName }
|
>
|
||||||
</span>
|
{ fixture.HomeTeam.ShortName }
|
||||||
}
|
</span>
|
||||||
if awayWon {
|
} else {
|
||||||
<span class="text-2xl">🏆</span>
|
<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">🏆</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ fixtureUploadPrompt(fixture *db.Fixture) {
|
templ fixtureUploadPrompt(fixture *db.Fixture) {
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
<div
|
||||||
<div class="text-4xl mb-3">📋</div>
|
x-data="{
|
||||||
<p class="text-lg text-text font-medium mb-2">No Result Uploaded</p>
|
open: false,
|
||||||
<p class="text-sm text-subtext1 mb-4">Upload match log files to record the result of this fixture.</p>
|
forfeitType: 'outright',
|
||||||
<a
|
forfeitTeam: '',
|
||||||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/results/upload", fixture.ID)) }
|
forfeitReason: '',
|
||||||
class="inline-block px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
}"
|
||||||
font-medium transition hover:cursor-pointer"
|
>
|
||||||
>
|
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||||||
Upload Match Logs
|
<div class="text-4xl mb-3">📋</div>
|
||||||
</a>
|
<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>
|
||||||
|
<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="open = true"
|
||||||
|
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)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side string, result *db.FixtureResult) {
|
templ forfeitModal(fixture *db.Fixture) {
|
||||||
|
<div
|
||||||
|
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, season *db.Season, league *db.League) {
|
||||||
{{
|
{{
|
||||||
// Separate playing and bench players
|
// Separate playing and bench players
|
||||||
var playing []*db.PlayerWithPlayStatus
|
var playing []*db.PlayerWithPlayStatus
|
||||||
@@ -368,8 +729,8 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
|||||||
}}
|
}}
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||||
<h3 class="text-md font-bold text-text">
|
<h3 class="text-md font-bold">
|
||||||
{ team.Name }
|
@links.TeamNameLinkInSeason(team, season, league)
|
||||||
</h3>
|
</h3>
|
||||||
if team.Color != "" {
|
if team.Color != "" {
|
||||||
<span
|
<span
|
||||||
@@ -390,6 +751,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
|||||||
<thead class="bg-surface0 border-b border-surface1">
|
<thead class="bg-surface0 border-b border-surface1">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
||||||
@@ -402,9 +764,9 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
|||||||
<tbody class="divide-y divide-surface1">
|
<tbody class="divide-y divide-surface1">
|
||||||
for _, p := range playing {
|
for _, p := range playing {
|
||||||
<tr class="hover:bg-surface0 transition-colors">
|
<tr class="hover:bg-surface0 transition-colors">
|
||||||
<td class="px-3 py-2 text-sm text-text">
|
<td class="px-3 py-2 text-sm">
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
{ p.Player.DisplayName() }
|
@links.PlayerLink(p.Player)
|
||||||
if p.IsManager {
|
if p.IsManager {
|
||||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||||
★
|
★
|
||||||
@@ -418,6 +780,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
if p.Stats != nil {
|
if p.Stats != nil {
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-subtext0">{ intPtrStr(p.Stats.PeriodsPlayed) }</td>
|
||||||
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ intPtrStr(p.Stats.Score) }</td>
|
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ intPtrStr(p.Stats.Score) }</td>
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Goals) }</td>
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Goals) }</td>
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Assists) }</td>
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Assists) }</td>
|
||||||
@@ -426,7 +789,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
|||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Blocks) }</td>
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Blocks) }</td>
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Passes) }</td>
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Passes) }</td>
|
||||||
} else {
|
} else {
|
||||||
<td colspan="7" class="px-2 py-2 text-center text-xs text-subtext1">—</td>
|
<td colspan="8" class="px-2 py-2 text-center text-xs text-subtext1">—</td>
|
||||||
}
|
}
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -441,7 +804,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
|||||||
for _, p := range bench {
|
for _, p := range bench {
|
||||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded">
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded">
|
||||||
<span class="text-sm text-subtext1">
|
<span class="text-sm text-subtext1">
|
||||||
{ p.Player.DisplayName() }
|
@links.PlayerLink(p.Player)
|
||||||
</span>
|
</span>
|
||||||
if p.IsManager {
|
if p.IsManager {
|
||||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||||
@@ -463,8 +826,8 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
for _, p := range playing {
|
for _, p := range playing {
|
||||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface0 transition">
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface0 transition">
|
||||||
<span class="text-sm text-text">
|
<span class="text-sm">
|
||||||
{ p.Player.DisplayName() }
|
@links.PlayerLink(p.Player)
|
||||||
</span>
|
</span>
|
||||||
if p.IsManager {
|
if p.IsManager {
|
||||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||||
@@ -486,7 +849,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
|||||||
for _, p := range bench {
|
for _, p := range bench {
|
||||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded">
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded">
|
||||||
<span class="text-sm text-subtext1">
|
<span class="text-sm text-subtext1">
|
||||||
{ p.Player.DisplayName() }
|
@links.PlayerLink(p.Player)
|
||||||
</span>
|
</span>
|
||||||
if p.IsManager {
|
if p.IsManager {
|
||||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||||
@@ -566,7 +929,9 @@ templ fixtureFreeAgentSection(
|
|||||||
for _, n := range homeNominated {
|
for _, n := range homeNominated {
|
||||||
<div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition">
|
<div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition">
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
<span class="text-sm text-text">{ n.Player.DisplayName() }</span>
|
<span class="text-sm">
|
||||||
|
@links.PlayerLink(n.Player)
|
||||||
|
</span>
|
||||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||||
FA
|
FA
|
||||||
</span>
|
</span>
|
||||||
@@ -601,7 +966,9 @@ templ fixtureFreeAgentSection(
|
|||||||
for _, n := range awayNominated {
|
for _, n := range awayNominated {
|
||||||
<div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition">
|
<div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition">
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
<span class="text-sm text-text">{ n.Player.DisplayName() }</span>
|
<span class="text-sm">
|
||||||
|
@links.PlayerLink(n.Player)
|
||||||
|
</span>
|
||||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||||
FA
|
FA
|
||||||
</span>
|
</span>
|
||||||
@@ -838,7 +1205,7 @@ templ fixtureScheduleStatus(
|
|||||||
<div class="flex justify-center mt-4">
|
<div class="flex justify-center mt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click={ "window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Withdraw Proposal', message: 'Are you sure you want to withdraw your proposed time?', action: () => htmx.ajax('POST', '/fixtures/" + fmt.Sprint(fixture.ID) + "/schedule/" + fmt.Sprint(current.ID) + "/withdraw', { swap: 'none' }) } }))" }
|
onclick={ templ.JSUnsafeFuncCall("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Withdraw Proposal', message: 'Are you sure you want to withdraw your proposed time?', action: () => htmx.ajax('POST', '/fixtures/" + fmt.Sprint(fixture.ID) + "/schedule/" + fmt.Sprint(current.ID) + "/withdraw', { swap: 'none' }) } }))") }
|
||||||
class="px-4 py-2 bg-surface1 hover:bg-surface2 text-text rounded-lg
|
class="px-4 py-2 bg-surface1 hover:bg-surface2 text-text rounded-lg
|
||||||
font-medium transition hover:cursor-pointer"
|
font-medium transition hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -1065,7 +1432,7 @@ templ fixtureScheduleActions(
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click={ fmt.Sprintf("if (!$el.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Postpone Fixture', message: 'Are you sure you want to postpone this fixture? The current schedule will be cleared.', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/postpone', { swap: 'none', values: { reschedule_reason: $el.parentElement.querySelector('select').value } }) } }))", fixture.ID) }
|
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("if (!this.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Postpone Fixture', message: 'Are you sure you want to postpone this fixture? The current schedule will be cleared.', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/postpone', { swap: 'none', values: { reschedule_reason: this.parentElement.querySelector('select').value } }) } }))", fixture.ID)) }
|
||||||
class="w-full px-4 py-2 bg-peach hover:bg-peach/80 text-mantle rounded-lg
|
class="w-full px-4 py-2 bg-peach hover:bg-peach/80 text-mantle rounded-lg
|
||||||
font-medium transition hover:cursor-pointer"
|
font-medium transition hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -1092,7 +1459,7 @@ templ fixtureScheduleActions(
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click={ fmt.Sprintf("if (!$el.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Declare Forfeit', message: 'This action is IRREVERSIBLE. The fixture schedule will be permanently cancelled. Are you sure?', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/cancel', { swap: 'none', values: { reschedule_reason: $el.parentElement.querySelector('select').value } }) } }))", fixture.ID) }
|
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("if (!this.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Declare Forfeit', message: 'This action is IRREVERSIBLE. The fixture schedule will be permanently cancelled. Are you sure?', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/cancel', { swap: 'none', values: { reschedule_reason: this.parentElement.querySelector('select').value } }) } }))", fixture.ID)) }
|
||||||
class="w-full px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
class="w-full px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||||
font-medium transition hover:cursor-pointer"
|
font-medium transition hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
|
|||||||
611
internal/view/seasonsview/fixture_match_analysis.templ
Normal file
611
internal/view/seasonsview/fixture_match_analysis.templ
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
|
import "fmt"
|
||||||
|
import "sort"
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// teamAggStats holds aggregated stats for a single team in a fixture.
|
||||||
|
type teamAggStats struct {
|
||||||
|
Goals int
|
||||||
|
Assists int
|
||||||
|
PrimaryAssists int
|
||||||
|
SecondaryAssists int
|
||||||
|
Saves int
|
||||||
|
Shots int
|
||||||
|
Blocks int
|
||||||
|
Passes int
|
||||||
|
Turnovers int
|
||||||
|
Takeaways int
|
||||||
|
FaceoffsWon int
|
||||||
|
FaceoffsLost int
|
||||||
|
PostHits int
|
||||||
|
PossessionSec int
|
||||||
|
PlayersUsed int
|
||||||
|
}
|
||||||
|
|
||||||
|
func aggregateTeamStats(players []*db.PlayerWithPlayStatus) *teamAggStats {
|
||||||
|
agg := &teamAggStats{}
|
||||||
|
for _, p := range players {
|
||||||
|
if !p.Played || p.Stats == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agg.PlayersUsed++
|
||||||
|
if p.Stats.Goals != nil {
|
||||||
|
agg.Goals += *p.Stats.Goals
|
||||||
|
}
|
||||||
|
if p.Stats.Assists != nil {
|
||||||
|
agg.Assists += *p.Stats.Assists
|
||||||
|
}
|
||||||
|
if p.Stats.PrimaryAssists != nil {
|
||||||
|
agg.PrimaryAssists += *p.Stats.PrimaryAssists
|
||||||
|
}
|
||||||
|
if p.Stats.SecondaryAssists != nil {
|
||||||
|
agg.SecondaryAssists += *p.Stats.SecondaryAssists
|
||||||
|
}
|
||||||
|
if p.Stats.Saves != nil {
|
||||||
|
agg.Saves += *p.Stats.Saves
|
||||||
|
}
|
||||||
|
if p.Stats.Shots != nil {
|
||||||
|
agg.Shots += *p.Stats.Shots
|
||||||
|
}
|
||||||
|
if p.Stats.Blocks != nil {
|
||||||
|
agg.Blocks += *p.Stats.Blocks
|
||||||
|
}
|
||||||
|
if p.Stats.Passes != nil {
|
||||||
|
agg.Passes += *p.Stats.Passes
|
||||||
|
}
|
||||||
|
if p.Stats.Turnovers != nil {
|
||||||
|
agg.Turnovers += *p.Stats.Turnovers
|
||||||
|
}
|
||||||
|
if p.Stats.Takeaways != nil {
|
||||||
|
agg.Takeaways += *p.Stats.Takeaways
|
||||||
|
}
|
||||||
|
if p.Stats.FaceoffsWon != nil {
|
||||||
|
agg.FaceoffsWon += *p.Stats.FaceoffsWon
|
||||||
|
}
|
||||||
|
if p.Stats.FaceoffsLost != nil {
|
||||||
|
agg.FaceoffsLost += *p.Stats.FaceoffsLost
|
||||||
|
}
|
||||||
|
if p.Stats.PostHits != nil {
|
||||||
|
agg.PostHits += *p.Stats.PostHits
|
||||||
|
}
|
||||||
|
if p.Stats.PossessionTimeSec != nil {
|
||||||
|
agg.PossessionSec += *p.Stats.PossessionTimeSec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return agg
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPossession(seconds int) string {
|
||||||
|
m := seconds / 60
|
||||||
|
s := seconds % 60
|
||||||
|
return fmt.Sprintf("%d:%02d", m, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func faceoffPct(won, lost int) string {
|
||||||
|
total := won + lost
|
||||||
|
if total == 0 {
|
||||||
|
return "0%"
|
||||||
|
}
|
||||||
|
pct := float64(won) / float64(total) * 100
|
||||||
|
return fmt.Sprintf("%.0f%%", pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixtureMatchAnalysisTab renders the full Match Analysis tab for completed fixtures.
|
||||||
|
// Shows score, team stats comparison, match details, and top performers.
|
||||||
|
templ fixtureMatchAnalysisTab(
|
||||||
|
fixture *db.Fixture,
|
||||||
|
result *db.FixtureResult,
|
||||||
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
|
preview *db.MatchPreviewData,
|
||||||
|
) {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Score Display -->
|
||||||
|
@analysisScoreHeader(fixture, result)
|
||||||
|
|
||||||
|
<!-- Team Stats Comparison -->
|
||||||
|
@analysisTeamStatsComparison(fixture, rosters)
|
||||||
|
|
||||||
|
<!-- Top Performers -->
|
||||||
|
@analysisTopPerformers(fixture, rosters)
|
||||||
|
|
||||||
|
<!-- Standings Context (from preview data) -->
|
||||||
|
if preview != nil {
|
||||||
|
@analysisStandingsContext(fixture, preview)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// analysisScoreHeader renders the final score in a prominent broadcast-style display.
|
||||||
|
templ analysisScoreHeader(fixture *db.Fixture, result *db.FixtureResult) {
|
||||||
|
{{
|
||||||
|
isOT := strings.EqualFold(result.EndReason, "Overtime")
|
||||||
|
homeWon := result.Winner == "home"
|
||||||
|
awayWon := result.Winner == "away"
|
||||||
|
isForfeit := result.IsForfeit
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Final Score</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
if isForfeit {
|
||||||
|
@analysisForfeitDisplay(fixture, result)
|
||||||
|
} else {
|
||||||
|
<div class="flex items-center justify-center gap-6 sm:gap-10">
|
||||||
|
<!-- Home Team -->
|
||||||
|
<div class="flex flex-col items-center text-center flex-1">
|
||||||
|
if fixture.HomeTeam.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
|
||||||
|
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
|
||||||
|
</h3>
|
||||||
|
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", homeWon), templ.KV("text-text", !homeWon) }>
|
||||||
|
{ fmt.Sprint(result.HomeScore) }
|
||||||
|
</span>
|
||||||
|
if homeWon {
|
||||||
|
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="flex flex-col items-center shrink-0">
|
||||||
|
<span class="text-4xl text-subtext0 font-light">–</span>
|
||||||
|
if isOT {
|
||||||
|
<span class="mt-1 px-2 py-0.5 bg-peach/20 text-peach rounded text-xs font-bold">OT</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Away Team -->
|
||||||
|
<div class="flex flex-col items-center text-center flex-1">
|
||||||
|
if fixture.AwayTeam.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
|
||||||
|
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
|
||||||
|
</h3>
|
||||||
|
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", awayWon), templ.KV("text-text", !awayWon) }>
|
||||||
|
{ fmt.Sprint(result.AwayScore) }
|
||||||
|
</span>
|
||||||
|
if awayWon {
|
||||||
|
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// analysisForfeitDisplay renders a forfeit result in the analysis header.
|
||||||
|
templ analysisForfeitDisplay(fixture *db.Fixture, result *db.FixtureResult) {
|
||||||
|
{{
|
||||||
|
isMutualForfeit := result.ForfeitType != nil && *result.ForfeitType == "mutual"
|
||||||
|
isOutrightForfeit := result.ForfeitType != nil && *result.ForfeitType == "outright"
|
||||||
|
forfeitTeamName := ""
|
||||||
|
winnerTeamName := ""
|
||||||
|
if isOutrightForfeit && result.ForfeitTeam != nil {
|
||||||
|
if *result.ForfeitTeam == "home" {
|
||||||
|
forfeitTeamName = fixture.HomeTeam.Name
|
||||||
|
winnerTeamName = fixture.AwayTeam.Name
|
||||||
|
} else {
|
||||||
|
forfeitTeamName = fixture.AwayTeam.Name
|
||||||
|
winnerTeamName = fixture.HomeTeam.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class="flex flex-col items-center py-4 space-y-4">
|
||||||
|
if isMutualForfeit {
|
||||||
|
<span class="px-4 py-2 bg-peach/20 text-peach rounded-lg text-lg font-bold">MUTUAL FORFEIT</span>
|
||||||
|
<p class="text-sm text-subtext0">Both teams receive an overtime loss</p>
|
||||||
|
} else if isOutrightForfeit {
|
||||||
|
<span class="px-4 py-2 bg-red/20 text-red rounded-lg text-lg font-bold">FORFEIT</span>
|
||||||
|
<p class="text-sm text-subtext0">
|
||||||
|
{ forfeitTeamName } forfeited — { winnerTeamName } wins
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
if result.ForfeitReason != nil && *result.ForfeitReason != "" {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-3 max-w-md w-full text-center">
|
||||||
|
<p class="text-xs text-subtext1 font-medium mb-1">Reason</p>
|
||||||
|
<p class="text-sm text-subtext0">{ *result.ForfeitReason }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// analysisTeamStatsComparison renders aggregated team stats in the broadcast comparison layout.
|
||||||
|
templ analysisTeamStatsComparison(fixture *db.Fixture, rosters map[string][]*db.PlayerWithPlayStatus) {
|
||||||
|
{{
|
||||||
|
homeAgg := aggregateTeamStats(rosters["home"])
|
||||||
|
awayAgg := aggregateTeamStats(rosters["away"])
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Team Statistics</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Team Name Headers -->
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="flex-1 text-right pr-4">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
if fixture.HomeTeam.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<span class="text-sm font-bold text-text">{ fixture.HomeTeam.ShortName }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-28 sm:w-36 text-center shrink-0"></div>
|
||||||
|
<div class="flex-1 text-left pl-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-bold text-text">{ fixture.AwayTeam.ShortName }</span>
|
||||||
|
if fixture.AwayTeam.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Stats Rows -->
|
||||||
|
<div class="space-y-0">
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(homeAgg.Goals),
|
||||||
|
"Goals",
|
||||||
|
fmt.Sprint(awayAgg.Goals),
|
||||||
|
homeAgg.Goals > awayAgg.Goals,
|
||||||
|
awayAgg.Goals > homeAgg.Goals,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(homeAgg.Assists),
|
||||||
|
"Assists",
|
||||||
|
fmt.Sprint(awayAgg.Assists),
|
||||||
|
homeAgg.Assists > awayAgg.Assists,
|
||||||
|
awayAgg.Assists > homeAgg.Assists,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(homeAgg.Shots),
|
||||||
|
"Shots",
|
||||||
|
fmt.Sprint(awayAgg.Shots),
|
||||||
|
homeAgg.Shots > awayAgg.Shots,
|
||||||
|
awayAgg.Shots > homeAgg.Shots,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(homeAgg.Saves),
|
||||||
|
"Saves",
|
||||||
|
fmt.Sprint(awayAgg.Saves),
|
||||||
|
homeAgg.Saves > awayAgg.Saves,
|
||||||
|
awayAgg.Saves > homeAgg.Saves,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(homeAgg.Blocks),
|
||||||
|
"Blocks",
|
||||||
|
fmt.Sprint(awayAgg.Blocks),
|
||||||
|
homeAgg.Blocks > awayAgg.Blocks,
|
||||||
|
awayAgg.Blocks > homeAgg.Blocks,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(homeAgg.Passes),
|
||||||
|
"Passes",
|
||||||
|
fmt.Sprint(awayAgg.Passes),
|
||||||
|
homeAgg.Passes > awayAgg.Passes,
|
||||||
|
awayAgg.Passes > homeAgg.Passes,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(homeAgg.Takeaways),
|
||||||
|
"Takeaways",
|
||||||
|
fmt.Sprint(awayAgg.Takeaways),
|
||||||
|
homeAgg.Takeaways > awayAgg.Takeaways,
|
||||||
|
awayAgg.Takeaways > homeAgg.Takeaways,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(homeAgg.Turnovers),
|
||||||
|
"Turnovers",
|
||||||
|
fmt.Sprint(awayAgg.Turnovers),
|
||||||
|
homeAgg.Turnovers < awayAgg.Turnovers,
|
||||||
|
awayAgg.Turnovers < homeAgg.Turnovers,
|
||||||
|
)
|
||||||
|
<!-- Faceoffs -->
|
||||||
|
{{
|
||||||
|
homeFO := homeAgg.FaceoffsWon + homeAgg.FaceoffsLost
|
||||||
|
awayFO := awayAgg.FaceoffsWon + awayAgg.FaceoffsLost
|
||||||
|
homeFOStr := fmt.Sprintf("%d/%d", homeAgg.FaceoffsWon, homeFO)
|
||||||
|
awayFOStr := fmt.Sprintf("%d/%d", awayAgg.FaceoffsWon, awayFO)
|
||||||
|
}}
|
||||||
|
@previewStatRow(
|
||||||
|
homeFOStr,
|
||||||
|
"Faceoffs Won",
|
||||||
|
awayFOStr,
|
||||||
|
homeAgg.FaceoffsWon > awayAgg.FaceoffsWon,
|
||||||
|
awayAgg.FaceoffsWon > homeAgg.FaceoffsWon,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
faceoffPct(homeAgg.FaceoffsWon, homeAgg.FaceoffsLost),
|
||||||
|
"Faceoff %",
|
||||||
|
faceoffPct(awayAgg.FaceoffsWon, awayAgg.FaceoffsLost),
|
||||||
|
homeAgg.FaceoffsWon * (awayAgg.FaceoffsWon + awayAgg.FaceoffsLost) > awayAgg.FaceoffsWon * (homeAgg.FaceoffsWon + homeAgg.FaceoffsLost),
|
||||||
|
awayAgg.FaceoffsWon * (homeAgg.FaceoffsWon + homeAgg.FaceoffsLost) > homeAgg.FaceoffsWon * (awayAgg.FaceoffsWon + awayAgg.FaceoffsLost),
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(homeAgg.PostHits),
|
||||||
|
"Post Hits",
|
||||||
|
fmt.Sprint(awayAgg.PostHits),
|
||||||
|
homeAgg.PostHits > awayAgg.PostHits,
|
||||||
|
awayAgg.PostHits > homeAgg.PostHits,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
formatPossession(homeAgg.PossessionSec),
|
||||||
|
"Possession",
|
||||||
|
formatPossession(awayAgg.PossessionSec),
|
||||||
|
homeAgg.PossessionSec > awayAgg.PossessionSec,
|
||||||
|
awayAgg.PossessionSec > homeAgg.PossessionSec,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(homeAgg.PlayersUsed),
|
||||||
|
"Players Used",
|
||||||
|
fmt.Sprint(awayAgg.PlayersUsed),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// analysisTopPerformers shows the top players from each team based on score.
|
||||||
|
templ analysisTopPerformers(fixture *db.Fixture, rosters map[string][]*db.PlayerWithPlayStatus) {
|
||||||
|
{{
|
||||||
|
// Collect players who played and have stats, sorted by score descending
|
||||||
|
type scoredPlayer struct {
|
||||||
|
Player *db.Player
|
||||||
|
Stats *db.FixtureResultPlayerStats
|
||||||
|
IsManager bool
|
||||||
|
IsFreeAgent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
collectTop := func(players []*db.PlayerWithPlayStatus, limit int) []*scoredPlayer {
|
||||||
|
var scored []*scoredPlayer
|
||||||
|
for _, p := range players {
|
||||||
|
if !p.Played || p.Stats == nil || p.Player == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scored = append(scored, &scoredPlayer{
|
||||||
|
Player: p.Player,
|
||||||
|
Stats: p.Stats,
|
||||||
|
IsManager: p.IsManager,
|
||||||
|
IsFreeAgent: p.IsFreeAgent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(scored, func(i, j int) bool {
|
||||||
|
si, sj := 0, 0
|
||||||
|
if scored[i].Stats.Score != nil {
|
||||||
|
si = *scored[i].Stats.Score
|
||||||
|
}
|
||||||
|
if scored[j].Stats.Score != nil {
|
||||||
|
sj = *scored[j].Stats.Score
|
||||||
|
}
|
||||||
|
return si > sj
|
||||||
|
})
|
||||||
|
if len(scored) > limit {
|
||||||
|
scored = scored[:limit]
|
||||||
|
}
|
||||||
|
return scored
|
||||||
|
}
|
||||||
|
|
||||||
|
homeTop := collectTop(rosters["home"], 3)
|
||||||
|
awayTop := collectTop(rosters["away"], 3)
|
||||||
|
}}
|
||||||
|
if len(homeTop) > 0 || len(awayTop) > 0 {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Top Performers</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Home Top Performers -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
if fixture.HomeTeam.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<h3 class="text-md font-bold text-text">{ fixture.HomeTeam.Name }</h3>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
for i, p := range homeTop {
|
||||||
|
@topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Away Top Performers -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
if fixture.AwayTeam.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<h3 class="text-md font-bold text-text">{ fixture.AwayTeam.Name }</h3>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
for i, p := range awayTop {
|
||||||
|
@topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// topPerformerCard renders a single top performer card with key stats.
|
||||||
|
templ topPerformerCard(player *db.Player, stats *db.FixtureResultPlayerStats, isManager bool, isFreeAgent bool, rank int) {
|
||||||
|
{{
|
||||||
|
rankLabels := map[int]string{1: "🥇", 2: "🥈", 3: "🥉"}
|
||||||
|
rankLabel := rankLabels[rank]
|
||||||
|
}}
|
||||||
|
<div class="flex items-center gap-3 px-4 py-3 bg-surface0 border border-surface1 rounded-lg">
|
||||||
|
<span class="text-lg shrink-0">{ rankLabel }</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-sm font-medium truncate">
|
||||||
|
@links.PlayerLink(player)
|
||||||
|
</span>
|
||||||
|
if isManager {
|
||||||
|
<span class="px-1 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium shrink-0">
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
if isFreeAgent {
|
||||||
|
<span class="px-1 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium shrink-0">
|
||||||
|
FA
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 mt-1 text-xs text-subtext0">
|
||||||
|
if stats.Score != nil {
|
||||||
|
<span title="Score"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Score) }</span> SC</span>
|
||||||
|
}
|
||||||
|
if stats.Goals != nil {
|
||||||
|
<span title="Goals"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Goals) }</span> G</span>
|
||||||
|
}
|
||||||
|
if stats.Assists != nil {
|
||||||
|
<span title="Assists"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Assists) }</span> A</span>
|
||||||
|
}
|
||||||
|
if stats.Saves != nil {
|
||||||
|
<span title="Saves"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Saves) }</span> SV</span>
|
||||||
|
}
|
||||||
|
if stats.Shots != nil {
|
||||||
|
<span title="Shots"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Shots) }</span> SH</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// analysisStandingsContext shows how this result fits into the league standings.
|
||||||
|
templ analysisStandingsContext(fixture *db.Fixture, preview *db.MatchPreviewData) {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">League Context</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Team Name Headers -->
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="flex-1 text-right pr-4">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
if fixture.HomeTeam.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<span class="text-sm font-bold text-text">{ fixture.HomeTeam.ShortName }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-28 sm:w-36 text-center shrink-0"></div>
|
||||||
|
<div class="flex-1 text-left pl-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-bold text-text">{ fixture.AwayTeam.ShortName }</span>
|
||||||
|
if fixture.AwayTeam.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-0">
|
||||||
|
{{
|
||||||
|
homePos := ordinal(preview.HomePosition)
|
||||||
|
awayPos := ordinal(preview.AwayPosition)
|
||||||
|
if preview.HomePosition == 0 {
|
||||||
|
homePos = "N/A"
|
||||||
|
}
|
||||||
|
if preview.AwayPosition == 0 {
|
||||||
|
awayPos = "N/A"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@previewStatRow(
|
||||||
|
homePos,
|
||||||
|
"Position",
|
||||||
|
awayPos,
|
||||||
|
preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition,
|
||||||
|
preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.Points),
|
||||||
|
"Points",
|
||||||
|
fmt.Sprint(preview.AwayRecord.Points),
|
||||||
|
preview.HomeRecord.Points > preview.AwayRecord.Points,
|
||||||
|
preview.AwayRecord.Points > preview.HomeRecord.Points,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprintf("%d-%d-%d-%d",
|
||||||
|
preview.HomeRecord.Wins,
|
||||||
|
preview.HomeRecord.OvertimeWins,
|
||||||
|
preview.HomeRecord.OvertimeLosses,
|
||||||
|
preview.HomeRecord.Losses,
|
||||||
|
),
|
||||||
|
"Record",
|
||||||
|
fmt.Sprintf("%d-%d-%d-%d",
|
||||||
|
preview.AwayRecord.Wins,
|
||||||
|
preview.AwayRecord.OvertimeWins,
|
||||||
|
preview.AwayRecord.OvertimeLosses,
|
||||||
|
preview.AwayRecord.Losses,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
{{
|
||||||
|
homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst
|
||||||
|
awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst
|
||||||
|
}}
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprintf("%+d", homeDiff),
|
||||||
|
"Goal Diff",
|
||||||
|
fmt.Sprintf("%+d", awayDiff),
|
||||||
|
homeDiff > awayDiff,
|
||||||
|
awayDiff > homeDiff,
|
||||||
|
)
|
||||||
|
<!-- Recent Form -->
|
||||||
|
if len(preview.HomeRecentGames) > 0 || len(preview.AwayRecentGames) > 0 {
|
||||||
|
<div class="flex items-center py-3 border-b border-surface1 last:border-b-0">
|
||||||
|
<div class="flex-1 flex justify-end pr-4">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
for _, g := range preview.HomeRecentGames {
|
||||||
|
@gameOutcomeIcon(g)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-28 sm:w-36 text-center shrink-0">
|
||||||
|
<span class="text-xs font-semibold text-subtext0 uppercase tracking-wider">Form</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex pl-4">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
for _, g := range preview.AwayRecentGames {
|
||||||
|
@gameOutcomeIcon(g)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
435
internal/view/seasonsview/fixture_match_preview.templ
Normal file
435
internal/view/seasonsview/fixture_match_preview.templ
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
|
import "fmt"
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
// fixtureMatchPreviewTab renders the full Match Preview tab content.
|
||||||
|
// Shows team standings comparison, recent form, and full rosters side-by-side.
|
||||||
|
templ fixtureMatchPreviewTab(
|
||||||
|
fixture *db.Fixture,
|
||||||
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
|
preview *db.MatchPreviewData,
|
||||||
|
) {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Team Comparison Header -->
|
||||||
|
@matchPreviewHeader(fixture, preview)
|
||||||
|
|
||||||
|
<!-- Form Guide (Last 5 Games) -->
|
||||||
|
@matchPreviewFormGuide(fixture, preview)
|
||||||
|
|
||||||
|
<!-- Team Rosters -->
|
||||||
|
@matchPreviewRosters(fixture, rosters)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPreviewHeader renders the broadcast-style team comparison with standings.
|
||||||
|
templ matchPreviewHeader(fixture *db.Fixture, preview *db.MatchPreviewData) {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Team Comparison</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Team Names and VS -->
|
||||||
|
<div class="flex items-center justify-center gap-4 sm:gap-8 mb-8">
|
||||||
|
<!-- Home Team -->
|
||||||
|
<div class="flex flex-col items-center text-center flex-1">
|
||||||
|
if fixture.HomeTeam.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<h3 class="text-xl sm:text-2xl font-bold text-text">
|
||||||
|
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
|
||||||
|
</h3>
|
||||||
|
<span class="text-subtext0 text-sm font-mono mt-1">{ fixture.HomeTeam.ShortName }</span>
|
||||||
|
</div>
|
||||||
|
<!-- VS Divider -->
|
||||||
|
<div class="flex flex-col items-center shrink-0">
|
||||||
|
<span class="text-3xl sm:text-4xl font-bold text-subtext0">VS</span>
|
||||||
|
</div>
|
||||||
|
<!-- Away Team -->
|
||||||
|
<div class="flex flex-col items-center text-center flex-1">
|
||||||
|
if fixture.AwayTeam.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<h3 class="text-xl sm:text-2xl font-bold text-text">
|
||||||
|
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
|
||||||
|
</h3>
|
||||||
|
<span class="text-subtext0 text-sm font-mono mt-1">{ fixture.AwayTeam.ShortName }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Stats Comparison Grid -->
|
||||||
|
{{
|
||||||
|
homePos := ordinal(preview.HomePosition)
|
||||||
|
awayPos := ordinal(preview.AwayPosition)
|
||||||
|
if preview.HomePosition == 0 {
|
||||||
|
homePos = "N/A"
|
||||||
|
}
|
||||||
|
if preview.AwayPosition == 0 {
|
||||||
|
awayPos = "N/A"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class="space-y-0">
|
||||||
|
<!-- Position -->
|
||||||
|
@previewStatRow(
|
||||||
|
homePos,
|
||||||
|
"Position",
|
||||||
|
awayPos,
|
||||||
|
preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition,
|
||||||
|
preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition,
|
||||||
|
)
|
||||||
|
<!-- Points -->
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.Points),
|
||||||
|
"Points",
|
||||||
|
fmt.Sprint(preview.AwayRecord.Points),
|
||||||
|
preview.HomeRecord.Points > preview.AwayRecord.Points,
|
||||||
|
preview.AwayRecord.Points > preview.HomeRecord.Points,
|
||||||
|
)
|
||||||
|
<!-- Played -->
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.Played),
|
||||||
|
"Played",
|
||||||
|
fmt.Sprint(preview.AwayRecord.Played),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
<!-- Wins -->
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.Wins),
|
||||||
|
"Wins",
|
||||||
|
fmt.Sprint(preview.AwayRecord.Wins),
|
||||||
|
preview.HomeRecord.Wins > preview.AwayRecord.Wins,
|
||||||
|
preview.AwayRecord.Wins > preview.HomeRecord.Wins,
|
||||||
|
)
|
||||||
|
<!-- OT Wins -->
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.OvertimeWins),
|
||||||
|
"OT Wins",
|
||||||
|
fmt.Sprint(preview.AwayRecord.OvertimeWins),
|
||||||
|
preview.HomeRecord.OvertimeWins > preview.AwayRecord.OvertimeWins,
|
||||||
|
preview.AwayRecord.OvertimeWins > preview.HomeRecord.OvertimeWins,
|
||||||
|
)
|
||||||
|
<!-- OT Losses -->
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.OvertimeLosses),
|
||||||
|
"OT Losses",
|
||||||
|
fmt.Sprint(preview.AwayRecord.OvertimeLosses),
|
||||||
|
preview.HomeRecord.OvertimeLosses < preview.AwayRecord.OvertimeLosses,
|
||||||
|
preview.AwayRecord.OvertimeLosses < preview.HomeRecord.OvertimeLosses,
|
||||||
|
)
|
||||||
|
<!-- Losses -->
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.Losses),
|
||||||
|
"Losses",
|
||||||
|
fmt.Sprint(preview.AwayRecord.Losses),
|
||||||
|
preview.HomeRecord.Losses < preview.AwayRecord.Losses,
|
||||||
|
preview.AwayRecord.Losses < preview.HomeRecord.Losses,
|
||||||
|
)
|
||||||
|
<!-- Goals For -->
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.GoalsFor),
|
||||||
|
"Goals For",
|
||||||
|
fmt.Sprint(preview.AwayRecord.GoalsFor),
|
||||||
|
preview.HomeRecord.GoalsFor > preview.AwayRecord.GoalsFor,
|
||||||
|
preview.AwayRecord.GoalsFor > preview.HomeRecord.GoalsFor,
|
||||||
|
)
|
||||||
|
<!-- Goals Against -->
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.GoalsAgainst),
|
||||||
|
"Goals Against",
|
||||||
|
fmt.Sprint(preview.AwayRecord.GoalsAgainst),
|
||||||
|
preview.HomeRecord.GoalsAgainst < preview.AwayRecord.GoalsAgainst,
|
||||||
|
preview.AwayRecord.GoalsAgainst < preview.HomeRecord.GoalsAgainst,
|
||||||
|
)
|
||||||
|
<!-- Goal Difference -->
|
||||||
|
{{
|
||||||
|
homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst
|
||||||
|
awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst
|
||||||
|
homeDiffStr := fmt.Sprintf("%+d", homeDiff)
|
||||||
|
awayDiffStr := fmt.Sprintf("%+d", awayDiff)
|
||||||
|
}}
|
||||||
|
@previewStatRow(
|
||||||
|
homeDiffStr,
|
||||||
|
"Goal Diff",
|
||||||
|
awayDiffStr,
|
||||||
|
homeDiff > awayDiff,
|
||||||
|
awayDiff > homeDiff,
|
||||||
|
)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// previewStatRow renders a single comparison stat row in the broadcast-style layout.
|
||||||
|
// The stat label is centered, with home value on the left and away value on the right.
|
||||||
|
// homeHighlight/awayHighlight indicate which side has the better value.
|
||||||
|
templ previewStatRow(homeValue, label, awayValue string, homeHighlight, awayHighlight bool) {
|
||||||
|
<div class="flex items-center py-2.5 border-b border-surface1 last:border-b-0">
|
||||||
|
<!-- Home Value -->
|
||||||
|
<div class="flex-1 text-right pr-4">
|
||||||
|
<span class={ "text-lg font-bold", templ.KV("text-green", homeHighlight), templ.KV("text-text", !homeHighlight) }>
|
||||||
|
{ homeValue }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Label -->
|
||||||
|
<div class="w-28 sm:w-36 text-center shrink-0">
|
||||||
|
<span class="text-xs font-semibold text-subtext0 uppercase tracking-wider">{ label }</span>
|
||||||
|
</div>
|
||||||
|
<!-- Away Value -->
|
||||||
|
<div class="flex-1 text-left pl-4">
|
||||||
|
<span class={ "text-lg font-bold", templ.KV("text-green", awayHighlight), templ.KV("text-text", !awayHighlight) }>
|
||||||
|
{ awayValue }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPreviewFormGuide renders the recent form section with last 5 game outcome icons.
|
||||||
|
templ matchPreviewFormGuide(fixture *db.Fixture, preview *db.MatchPreviewData) {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Recent Form</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<!-- Home Team Form -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
if fixture.HomeTeam.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<h3 class="text-md font-bold text-text">{ fixture.HomeTeam.Name }</h3>
|
||||||
|
</div>
|
||||||
|
if len(preview.HomeRecentGames) == 0 {
|
||||||
|
<p class="text-subtext1 text-sm">No recent matches played</p>
|
||||||
|
} else {
|
||||||
|
<!-- Outcome Icons: chronological (oldest → newest, left → right) -->
|
||||||
|
<div class="flex items-center gap-1.5 mb-4">
|
||||||
|
for _, g := range preview.HomeRecentGames {
|
||||||
|
@gameOutcomeIcon(g)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Recent Results List: most recent first -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
for i := len(preview.HomeRecentGames) - 1; i >= 0; i-- {
|
||||||
|
@recentGameRow(preview.HomeRecentGames[i])
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Away Team Form -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
if fixture.AwayTeam.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<h3 class="text-md font-bold text-text">{ fixture.AwayTeam.Name }</h3>
|
||||||
|
</div>
|
||||||
|
if len(preview.AwayRecentGames) == 0 {
|
||||||
|
<p class="text-subtext1 text-sm">No recent matches played</p>
|
||||||
|
} else {
|
||||||
|
<!-- Outcome Icons: chronological (oldest → newest, left → right) -->
|
||||||
|
<div class="flex items-center gap-1.5 mb-4">
|
||||||
|
for _, g := range preview.AwayRecentGames {
|
||||||
|
@gameOutcomeIcon(g)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Recent Results List: most recent first -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
for i := len(preview.AwayRecentGames) - 1; i >= 0; i-- {
|
||||||
|
@recentGameRow(preview.AwayRecentGames[i])
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// outcomeStyle holds the styling info for a game outcome type.
|
||||||
|
type outcomeStyle struct {
|
||||||
|
iconBg string // Background class for the icon badge
|
||||||
|
rowBg string // Background class for the row
|
||||||
|
text string // Text color class
|
||||||
|
label string // Short label (W, L, OW, OL, D, F)
|
||||||
|
fullLabel string // Full label for row display (W, OTW, OTL, L, D, FF)
|
||||||
|
desc string // Human-readable description (Win, Loss, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOutcomeStyle(outcomeType string) outcomeStyle {
|
||||||
|
switch outcomeType {
|
||||||
|
case "W":
|
||||||
|
return outcomeStyle{"bg-green/20", "bg-green/10", "text-green", "W", "W", "Win"}
|
||||||
|
case "OTW":
|
||||||
|
return outcomeStyle{"bg-yellow/20", "bg-yellow/10", "text-yellow", "OW", "OTW", "OT Win"}
|
||||||
|
case "OTL":
|
||||||
|
return outcomeStyle{"bg-peach/20", "bg-peach/10", "text-peach", "OL", "OTL", "OT Loss"}
|
||||||
|
case "L":
|
||||||
|
return outcomeStyle{"bg-red/20", "bg-red/10", "text-red", "L", "L", "Loss"}
|
||||||
|
case "D":
|
||||||
|
return outcomeStyle{"bg-overlay0/20", "bg-overlay0/10", "text-overlay0", "D", "D", "Draw"}
|
||||||
|
case "F":
|
||||||
|
return outcomeStyle{"bg-red/30", "bg-red/15", "text-red", "F", "FF", "Forfeit"}
|
||||||
|
default:
|
||||||
|
return outcomeStyle{"bg-surface1", "bg-surface0", "text-subtext0", "?", "?", "Unknown"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameOutcomeIcon renders a single game outcome as a colored badge.
|
||||||
|
templ gameOutcomeIcon(outcome *db.GameOutcome) {
|
||||||
|
{{
|
||||||
|
style := getOutcomeStyle(outcome.Type)
|
||||||
|
tooltip := ""
|
||||||
|
if outcome.Opponent != nil {
|
||||||
|
tooltip = fmt.Sprintf("%s vs %s", style.desc, outcome.Opponent.Name)
|
||||||
|
if outcome.IsForfeit {
|
||||||
|
tooltip += " (Forfeit)"
|
||||||
|
} else if outcome.Score != "" {
|
||||||
|
tooltip += fmt.Sprintf(" (%s)", outcome.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<span
|
||||||
|
class={ "inline-flex items-center justify-center w-9 h-9 rounded-md text-xs font-bold cursor-default", style.iconBg, style.text }
|
||||||
|
title={ tooltip }
|
||||||
|
>
|
||||||
|
{ style.label }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentGameRow renders a single recent game result as a compact row.
|
||||||
|
templ recentGameRow(outcome *db.GameOutcome) {
|
||||||
|
{{
|
||||||
|
style := getOutcomeStyle(outcome.Type)
|
||||||
|
opponentName := "Unknown"
|
||||||
|
if outcome.Opponent != nil {
|
||||||
|
opponentName = outcome.Opponent.Name
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class={ "flex items-center gap-3 px-3 py-2 rounded-lg", style.rowBg }>
|
||||||
|
<span class={ "text-xs font-bold w-8 text-center", style.text }>{ style.fullLabel }</span>
|
||||||
|
<span class="text-sm text-text">vs { opponentName }</span>
|
||||||
|
if outcome.IsForfeit {
|
||||||
|
<span class="text-xs text-red ml-auto font-medium">Forfeit</span>
|
||||||
|
} else if outcome.Score != "" {
|
||||||
|
<span class="text-sm text-subtext0 ml-auto font-mono">{ outcome.Score }</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPreviewRosters renders team rosters side-by-side for the match preview.
|
||||||
|
templ matchPreviewRosters(
|
||||||
|
fixture *db.Fixture,
|
||||||
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
|
) {
|
||||||
|
{{
|
||||||
|
homePlayers := rosters["home"]
|
||||||
|
awayPlayers := rosters["away"]
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Team Rosters</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Home Team Roster -->
|
||||||
|
@previewRosterColumn(fixture.HomeTeam, homePlayers, fixture.Season, fixture.League)
|
||||||
|
<!-- Away Team Roster -->
|
||||||
|
@previewRosterColumn(fixture.AwayTeam, awayPlayers, fixture.Season, fixture.League)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// previewRosterColumn renders a single team's roster for the match preview.
|
||||||
|
templ previewRosterColumn(
|
||||||
|
team *db.Team,
|
||||||
|
players []*db.PlayerWithPlayStatus,
|
||||||
|
season *db.Season,
|
||||||
|
league *db.League,
|
||||||
|
) {
|
||||||
|
{{
|
||||||
|
// Separate managers and regular players
|
||||||
|
var managers []*db.PlayerWithPlayStatus
|
||||||
|
var roster []*db.PlayerWithPlayStatus
|
||||||
|
for _, p := range players {
|
||||||
|
if p.IsManager {
|
||||||
|
managers = append(managers, p)
|
||||||
|
} else {
|
||||||
|
roster = append(roster, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort roster alphabetically by display name
|
||||||
|
sort.Slice(roster, func(i, j int) bool {
|
||||||
|
return roster[i].Player.DisplayName() < roster[j].Player.DisplayName()
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
<div>
|
||||||
|
<!-- Team Header -->
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
if team.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3.5 h-3.5 rounded-full shrink-0 border border-surface1"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<h3 class="text-md font-bold">
|
||||||
|
@links.TeamNameLinkInSeason(team, season, league)
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-subtext0">
|
||||||
|
{ fmt.Sprint(len(players)) } players
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
if len(players) == 0 {
|
||||||
|
<p class="text-subtext1 text-sm text-center py-4">No players on roster.</p>
|
||||||
|
} else {
|
||||||
|
<div class="space-y-1">
|
||||||
|
<!-- Manager(s) -->
|
||||||
|
for _, p := range managers {
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface0 border border-surface1">
|
||||||
|
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium shrink-0">
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
@links.PlayerLink(p.Player)
|
||||||
|
</span>
|
||||||
|
if p.IsFreeAgent {
|
||||||
|
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||||
|
FA
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Regular Players -->
|
||||||
|
for _, p := range roster {
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface0 transition">
|
||||||
|
<span class="text-sm">
|
||||||
|
@links.PlayerLink(p.Player)
|
||||||
|
</span>
|
||||||
|
if p.IsFreeAgent {
|
||||||
|
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||||
|
FA
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package seasonsview
|
|||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
templ FixtureReviewResultPage(
|
templ FixtureReviewResultPage(
|
||||||
@@ -22,7 +23,13 @@ templ FixtureReviewResultPage(
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-text mb-1">Review Match Result</h1>
|
<h1 class="text-2xl font-bold text-text mb-1">Review Match Result</h1>
|
||||||
<p class="text-sm text-subtext1">
|
<p class="text-sm text-subtext1">
|
||||||
{ fixture.HomeTeam.Name } vs { fixture.AwayTeam.Name }
|
<span>
|
||||||
|
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
|
||||||
|
</span>
|
||||||
|
vs
|
||||||
|
<span>
|
||||||
|
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
|
||||||
|
</span>
|
||||||
<span class="text-subtext0 ml-1">
|
<span class="text-subtext0 ml-1">
|
||||||
Round { fmt.Sprint(fixture.Round) }
|
Round { fmt.Sprint(fixture.Round) }
|
||||||
</span>
|
</span>
|
||||||
@@ -96,12 +103,16 @@ templ FixtureReviewResultPage(
|
|||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex items-center justify-center gap-8 py-4">
|
<div class="flex items-center justify-center gap-8 py-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-subtext0 mb-1">{ fixture.HomeTeam.Name }</p>
|
<p class="text-sm text-subtext0 mb-1">
|
||||||
|
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
|
||||||
|
</p>
|
||||||
<p class="text-4xl font-bold text-text">{ fmt.Sprint(result.HomeScore) }</p>
|
<p class="text-4xl font-bold text-text">{ fmt.Sprint(result.HomeScore) }</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-2xl text-subtext0 font-light">—</div>
|
<div class="text-2xl text-subtext0 font-light">—</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-subtext0 mb-1">{ fixture.AwayTeam.Name }</p>
|
<p class="text-sm text-subtext0 mb-1">
|
||||||
|
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
|
||||||
|
</p>
|
||||||
<p class="text-4xl font-bold text-text">{ fmt.Sprint(result.AwayScore) }</p>
|
<p class="text-4xl font-bold text-text">{ fmt.Sprint(result.AwayScore) }</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,8 +138,8 @@ templ FixtureReviewResultPage(
|
|||||||
</div>
|
</div>
|
||||||
<!-- Player Stats Tables -->
|
<!-- Player Stats Tables -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
@reviewTeamStats(fixture.HomeTeam, result, "home")
|
@reviewTeamStats(fixture.HomeTeam, result, "home", fixture.Season, fixture.League)
|
||||||
@reviewTeamStats(fixture.AwayTeam, result, "away")
|
@reviewTeamStats(fixture.AwayTeam, result, "away", fixture.Season, fixture.League)
|
||||||
</div>
|
</div>
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
@@ -149,14 +160,14 @@ templ FixtureReviewResultPage(
|
|||||||
Finalize Result
|
Finalize Result
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click={ fmt.Sprintf("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Discard Result', message: 'Are you sure you want to discard this result? You will need to re-upload the match logs.', action: () => htmx.ajax('POST', '/fixtures/%d/results/discard', { swap: 'none' }) } }))", fixture.ID) }
|
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Discard Result', message: 'Are you sure you want to discard this result? You will need to re-upload the match logs.', action: () => htmx.ajax('POST', '/fixtures/%d/results/discard', { swap: 'none' }) } }))", fixture.ID)) }
|
||||||
class="px-6 py-3 bg-red hover:bg-red/80 text-mantle rounded-lg
|
class="px-6 py-3 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||||
font-medium transition hover:cursor-pointer text-lg"
|
font-medium transition hover:cursor-pointer text-lg"
|
||||||
>
|
>
|
||||||
Discard & Re-upload
|
Discard & Re-upload
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,7 +175,7 @@ templ FixtureReviewResultPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string, season *db.Season, league *db.League) {
|
||||||
{{
|
{{
|
||||||
// Collect unique players for this team across all periods
|
// Collect unique players for this team across all periods
|
||||||
// We'll show the period 3 (final/cumulative) stats
|
// We'll show the period 3 (final/cumulative) stats
|
||||||
@@ -197,7 +208,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
|||||||
} else {
|
} else {
|
||||||
Away —
|
Away —
|
||||||
}
|
}
|
||||||
{ team.Name }
|
@links.TeamNameLinkInSeason(team, season, league)
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
@@ -205,6 +216,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
|||||||
<thead class="bg-surface0 border-b border-surface1">
|
<thead class="bg-surface0 border-b border-surface1">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
||||||
@@ -217,10 +229,12 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
|||||||
<tbody class="divide-y divide-surface1">
|
<tbody class="divide-y divide-surface1">
|
||||||
for _, ps := range finalStats {
|
for _, ps := range finalStats {
|
||||||
<tr class="hover:bg-surface0 transition-colors">
|
<tr class="hover:bg-surface0 transition-colors">
|
||||||
<td class="px-3 py-2 text-sm text-text">
|
<td class="px-3 py-2 text-sm">
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
{ ps.Username }
|
if ps.PlayerID != nil {
|
||||||
if ps.PlayerID == nil {
|
@links.PlayerLinkFromStats(*ps.PlayerID, ps.Username)
|
||||||
|
} else {
|
||||||
|
<span class="text-text">{ ps.Username }</span>
|
||||||
<span class="text-yellow text-xs" title="Unmapped player">?</span>
|
<span class="text-yellow text-xs" title="Unmapped player">?</span>
|
||||||
}
|
}
|
||||||
if ps.Stats.IsFreeAgent {
|
if ps.Stats.IsFreeAgent {
|
||||||
@@ -230,6 +244,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-subtext0">{ intPtrStr(ps.Stats.PeriodsPlayed) }</td>
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Goals) }</td>
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Goals) }</td>
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Assists) }</td>
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Assists) }</td>
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Saves) }</td>
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Saves) }</td>
|
||||||
@@ -241,7 +256,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
|||||||
}
|
}
|
||||||
if len(finalStats) == 0 {
|
if len(finalStats) == 0 {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="px-3 py-4 text-center text-sm text-subtext1">
|
<td colspan="9" class="px-3 py-4 text-center text-sm text-subtext1">
|
||||||
No player stats recorded
|
No player stats recorded
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -258,3 +273,20 @@ func intPtrStr(v *int) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprint(*v)
|
return fmt.Sprint(*v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ordinal(n int) string {
|
||||||
|
suffix := "th"
|
||||||
|
if n%100 >= 11 && n%100 <= 13 {
|
||||||
|
// 11th, 12th, 13th
|
||||||
|
} else {
|
||||||
|
switch n % 10 {
|
||||||
|
case 1:
|
||||||
|
suffix = "st"
|
||||||
|
case 2:
|
||||||
|
suffix = "nd"
|
||||||
|
case 3:
|
||||||
|
suffix = "rd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d%s", n, suffix)
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ templ LeaguesSection(season *db.Season, allLeagues []*db.League) {
|
|||||||
if canRemoveLeague {
|
if canRemoveLeague {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click={ "window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Remove League', message: 'Are you sure you want to remove " + league.Name + " from this season?', action: () => htmx.ajax('DELETE', '/seasons/" + season.ShortName + "/leagues/" + league.ShortName + "', { target: '#leagues-section', swap: 'outerHTML' }) } }))" }
|
onclick={ templ.JSUnsafeFuncCall("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Remove League', message: 'Are you sure you want to remove " + league.Name + " from this season?', action: () => htmx.ajax('DELETE', '/seasons/" + season.ShortName + "/leagues/" + league.ShortName + "', { target: '#leagues-section', swap: 'outerHTML' }) } }))") }
|
||||||
class="text-red hover:text-red/75 hover:cursor-pointer ml-1"
|
class="text-red hover:text-red/75 hover:cursor-pointer ml-1"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -115,29 +115,28 @@ templ SeasonsList(seasons *db.List[db.Season]) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Date Info -->
|
<!-- Date Info -->
|
||||||
{{
|
{{
|
||||||
now := time.Now()
|
listStatus := s.GetStatus()
|
||||||
}}
|
}}
|
||||||
<div class="text-xs text-subtext1 mt-auto">
|
<div class="text-xs text-subtext1 mt-auto">
|
||||||
if now.Before(s.StartDate) {
|
switch listStatus {
|
||||||
|
case db.StatusUpcoming:
|
||||||
Starts: { formatDate(s.StartDate) }
|
Starts: { formatDate(s.StartDate) }
|
||||||
} else if !s.FinalsStartDate.IsZero() {
|
case db.StatusCompleted:
|
||||||
// Finals are scheduled
|
if !s.FinalsEndDate.IsZero() {
|
||||||
if !s.FinalsEndDate.IsZero() && now.After(s.FinalsEndDate.Time) {
|
|
||||||
Completed: { formatDate(s.FinalsEndDate.Time) }
|
Completed: { formatDate(s.FinalsEndDate.Time) }
|
||||||
} else if now.After(s.FinalsStartDate.Time) {
|
} else if !s.EndDate.IsZero() {
|
||||||
Finals Started: { formatDate(s.FinalsStartDate.Time) }
|
Completed: { formatDate(s.EndDate.Time) }
|
||||||
} else {
|
|
||||||
Finals Start: { formatDate(s.FinalsStartDate.Time) }
|
|
||||||
}
|
}
|
||||||
} else if !s.EndDate.IsZero() && now.After(s.EndDate.Time) {
|
case db.StatusFinals:
|
||||||
// No finals scheduled and regular season ended
|
Finals Started: { formatDate(s.FinalsStartDate.Time) }
|
||||||
Completed: { formatDate(s.EndDate.Time) }
|
case db.StatusFinalsSoon:
|
||||||
} else {
|
Finals Start: { formatDate(s.FinalsStartDate.Time) }
|
||||||
|
default:
|
||||||
Started: { formatDate(s.StartDate) }
|
Started: { formatDate(s.StartDate) }
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
323
internal/view/seasonsview/playoff_bracket.templ
Normal file
323
internal/view/seasonsview/playoff_bracket.templ
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// PlayoffBracketView renders the full bracket visualization
|
||||||
|
templ PlayoffBracketView(season *db.Season, league *db.League, bracket *db.PlayoffBracket) {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Bracket Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-text flex items-center gap-2">
|
||||||
|
<span class="text-yellow">★</span>
|
||||||
|
Finals Bracket
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-subtext0 mt-1">
|
||||||
|
{ formatLabel(bracket.Format) }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
@playoffStatusBadge(bracket.Status)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Bracket Display -->
|
||||||
|
switch bracket.Format {
|
||||||
|
case db.PlayoffFormat5to6:
|
||||||
|
@bracket5to6(season, league, bracket)
|
||||||
|
case db.PlayoffFormat7to9:
|
||||||
|
@bracket7to9(season, league, bracket)
|
||||||
|
case db.PlayoffFormat10to15:
|
||||||
|
@bracket10to15(season, league, bracket)
|
||||||
|
}
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="flex items-center gap-6 text-xs text-subtext0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-0 border-t-2 border-green"></div>
|
||||||
|
<span>Winner</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-0 border-t-2 border-red border-dashed"></div>
|
||||||
|
<span>Loser</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/bracket-lines.js"></script>
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('[data-series-url]').forEach(function(card) {
|
||||||
|
card.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('a')) {
|
||||||
|
window.location.href = card.getAttribute('data-series-url');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 5-6 TEAMS FORMAT
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Round 1: [Upper Bracket] [Lower Bracket]
|
||||||
|
// Round 2: [Upper Final] [Lower Final]
|
||||||
|
// Round 3: [Grand Final]
|
||||||
|
templ bracket5to6(season *db.Season, league *db.League, bracket *db.PlayoffBracket) {
|
||||||
|
{{
|
||||||
|
s := seriesByNumber(bracket.Series)
|
||||||
|
conns := connectionsJSON(bracket.Series)
|
||||||
|
}}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="relative min-w-[500px]" data-bracket-lines data-connections={ conns }>
|
||||||
|
<svg data-bracket-svg class="absolute inset-0 w-full h-full pointer-events-none" style="z-index: 0;"></svg>
|
||||||
|
<div class="relative" style="z-index: 1;">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
@seriesCard(season, league, s[1])
|
||||||
|
@seriesCard(season, league, s[2])
|
||||||
|
</div>
|
||||||
|
<div class="h-16"></div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
@seriesCard(season, league, s[3])
|
||||||
|
@seriesCard(season, league, s[4])
|
||||||
|
</div>
|
||||||
|
<div class="h-16"></div>
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
@seriesCard(season, league, s[5])
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 7-9 TEAMS FORMAT
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Round 1 (Quarter Finals): [QF1] [QF2]
|
||||||
|
// Round 2 (Semi Finals): [SF1] [SF2]
|
||||||
|
// Round 3: [3rd Place] [Grand Final]
|
||||||
|
templ bracket7to9(season *db.Season, league *db.League, bracket *db.PlayoffBracket) {
|
||||||
|
{{
|
||||||
|
s := seriesByNumber(bracket.Series)
|
||||||
|
conns := connectionsJSON(bracket.Series)
|
||||||
|
}}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="relative min-w-[500px]" data-bracket-lines data-connections={ conns }>
|
||||||
|
<svg data-bracket-svg class="absolute inset-0 w-full h-full pointer-events-none" style="z-index: 0;"></svg>
|
||||||
|
<div class="relative" style="z-index: 1;">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
@seriesCard(season, league, s[1])
|
||||||
|
@seriesCard(season, league, s[2])
|
||||||
|
</div>
|
||||||
|
<div class="h-16"></div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
@seriesCard(season, league, s[3])
|
||||||
|
@seriesCard(season, league, s[4])
|
||||||
|
</div>
|
||||||
|
<div class="h-16"></div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
@seriesCard(season, league, s[5])
|
||||||
|
@seriesCard(season, league, s[6])
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 10-15 TEAMS FORMAT
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 4 invisible columns, cards placed into specific cells:
|
||||||
|
// Row 1: EF1(col2) EF2(col3)
|
||||||
|
// Row 2: QF1(col1) QF2(col4)
|
||||||
|
// Row 3: SF1(col2) SF2(col3)
|
||||||
|
// Row 4: PF1(col2) PF2(col3)
|
||||||
|
// Row 5: 3rd(col2)
|
||||||
|
// Row 6: GF(col3)
|
||||||
|
templ bracket10to15(season *db.Season, league *db.League, bracket *db.PlayoffBracket) {
|
||||||
|
{{
|
||||||
|
s := seriesByNumber(bracket.Series)
|
||||||
|
conns := connectionsJSON(bracket.Series)
|
||||||
|
}}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="relative min-w-[700px]" data-bracket-lines data-connections={ conns }>
|
||||||
|
<svg data-bracket-svg class="absolute inset-0 w-full h-full pointer-events-none" style="z-index: 0;"></svg>
|
||||||
|
<div class="relative" style="z-index: 1;">
|
||||||
|
<!-- Row 1: EF1(c2) EF2(c3) -->
|
||||||
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
<div></div>
|
||||||
|
@seriesCard(season, league, s[3])
|
||||||
|
@seriesCard(season, league, s[4])
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-16"></div>
|
||||||
|
<!-- Row 2: QF1(c1) QF2(c4) -->
|
||||||
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
@seriesCard(season, league, s[1])
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
@seriesCard(season, league, s[2])
|
||||||
|
</div>
|
||||||
|
<div class="h-16"></div>
|
||||||
|
<!-- Row 3: SF1(c2) SF2(c3) -->
|
||||||
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
<div></div>
|
||||||
|
@seriesCard(season, league, s[5])
|
||||||
|
@seriesCard(season, league, s[6])
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-16"></div>
|
||||||
|
<!-- Row 4: PF1(c2) PF2(c3) -->
|
||||||
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
<div></div>
|
||||||
|
@seriesCard(season, league, s[7])
|
||||||
|
@seriesCard(season, league, s[8])
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-16"></div>
|
||||||
|
<!-- Row 5: 3rd Place(c2) -->
|
||||||
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
<div></div>
|
||||||
|
@seriesCard(season, league, s[9])
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-16"></div>
|
||||||
|
<!-- Row 6: Grand Final(c3) -->
|
||||||
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
@seriesCard(season, league, s[10])
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// SHARED COMPONENTS
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) {
|
||||||
|
{{
|
||||||
|
hasTeams := series.Team1 != nil || series.Team2 != nil
|
||||||
|
seriesURL := fmt.Sprintf("/series/%d", series.ID)
|
||||||
|
}}
|
||||||
|
<div
|
||||||
|
data-series={ fmt.Sprint(series.SeriesNumber) }
|
||||||
|
if hasTeams {
|
||||||
|
data-series-url={ seriesURL }
|
||||||
|
}
|
||||||
|
class={ "bg-surface0 border rounded-lg overflow-hidden",
|
||||||
|
templ.KV("border-blue/50", series.Status == db.SeriesStatusInProgress),
|
||||||
|
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress),
|
||||||
|
templ.KV("hover:bg-surface1 hover:cursor-pointer transition", hasTeams) }
|
||||||
|
>
|
||||||
|
<!-- Series Header -->
|
||||||
|
<div class="bg-mantle px-3 py-1.5 flex items-center justify-between border-b border-surface1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-semibold text-subtext0">{ series.Label }</span>
|
||||||
|
@seriesFormatBadge(series.MatchesToWin)
|
||||||
|
</div>
|
||||||
|
@seriesStatusBadge(series.Status)
|
||||||
|
</div>
|
||||||
|
<!-- Teams -->
|
||||||
|
<div class="divide-y divide-surface1">
|
||||||
|
@seriesTeamRow(season, league, series.Team1, series.Team1Seed, series.Team1Wins,
|
||||||
|
series.WinnerTeamID, series.MatchesToWin)
|
||||||
|
@seriesTeamRow(season, league, series.Team2, series.Team2Seed, series.Team2Wins,
|
||||||
|
series.WinnerTeamID, series.MatchesToWin)
|
||||||
|
</div>
|
||||||
|
<!-- Series Score -->
|
||||||
|
if series.MatchesToWin > 1 {
|
||||||
|
<div class="bg-mantle px-3 py-1 text-center text-xs text-subtext0 border-t border-surface1">
|
||||||
|
{ fmt.Sprint(series.Team1Wins) } - { fmt.Sprint(series.Team2Wins) }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *int, wins int, winnerID *int, matchesToWin int) {
|
||||||
|
{{
|
||||||
|
isWinner := false
|
||||||
|
if team != nil && winnerID != nil {
|
||||||
|
isWinner = team.ID == *winnerID
|
||||||
|
}
|
||||||
|
isTBD := team == nil
|
||||||
|
}}
|
||||||
|
<div class={ "flex items-center justify-between px-3 py-2",
|
||||||
|
templ.KV("bg-green/5", isWinner) }>
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
if seed != nil {
|
||||||
|
<span class="text-xs font-mono text-subtext0 w-4 text-right flex-shrink-0">
|
||||||
|
{ fmt.Sprint(*seed) }
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-xs font-mono text-subtext0 w-4 text-right flex-shrink-0">-</span>
|
||||||
|
}
|
||||||
|
if isTBD {
|
||||||
|
<span class="text-sm text-subtext1 italic">TBD</span>
|
||||||
|
} else {
|
||||||
|
<div class="truncate">
|
||||||
|
@links.TeamLinkInSeason(team, season, league)
|
||||||
|
</div>
|
||||||
|
if isWinner {
|
||||||
|
<span class="text-green text-xs flex-shrink-0">✓</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if matchesToWin > 1 {
|
||||||
|
<span class={ "text-sm font-mono flex-shrink-0 ml-2",
|
||||||
|
templ.KV("text-text", !isWinner),
|
||||||
|
templ.KV("text-green font-bold", isWinner) }>
|
||||||
|
{ fmt.Sprint(wins) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ playoffStatusBadge(status db.PlayoffStatus) {
|
||||||
|
switch status {
|
||||||
|
case db.PlayoffStatusUpcoming:
|
||||||
|
<span class="px-2 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium">
|
||||||
|
Upcoming
|
||||||
|
</span>
|
||||||
|
case db.PlayoffStatusInProgress:
|
||||||
|
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||||
|
In Progress
|
||||||
|
</span>
|
||||||
|
case db.PlayoffStatusCompleted:
|
||||||
|
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesFormatBadge(matchesToWin int) {
|
||||||
|
{{
|
||||||
|
label := fmt.Sprintf("BO%d", matchesToWin*2-1)
|
||||||
|
}}
|
||||||
|
<span class="px-1.5 py-0.5 bg-surface1 text-subtext1 rounded text-xs font-mono">
|
||||||
|
{ label }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesStatusBadge(status db.SeriesStatus) {
|
||||||
|
switch status {
|
||||||
|
case db.SeriesStatusPending:
|
||||||
|
<span class="px-1.5 py-0.5 bg-surface1 text-subtext0 rounded text-xs">
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
case db.SeriesStatusInProgress:
|
||||||
|
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs">
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
case db.SeriesStatusCompleted:
|
||||||
|
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs">
|
||||||
|
Complete
|
||||||
|
</span>
|
||||||
|
case db.SeriesStatusBye:
|
||||||
|
<span class="px-1.5 py-0.5 bg-surface1 text-subtext0 rounded text-xs">
|
||||||
|
Bye
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
86
internal/view/seasonsview/playoff_helpers.go
Normal file
86
internal/view/seasonsview/playoff_helpers.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seriesByNumber returns a map of series_number -> *PlayoffSeries for quick lookup
|
||||||
|
func seriesByNumber(series []*db.PlayoffSeries) map[int]*db.PlayoffSeries {
|
||||||
|
m := make(map[int]*db.PlayoffSeries, len(series))
|
||||||
|
for _, s := range series {
|
||||||
|
m[s.SeriesNumber] = s
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatLabel returns a human-readable format description
|
||||||
|
func formatLabel(format db.PlayoffFormat) string {
|
||||||
|
switch format {
|
||||||
|
case db.PlayoffFormat5to6:
|
||||||
|
return "Top 5 qualify"
|
||||||
|
case db.PlayoffFormat7to9:
|
||||||
|
return "Top 6 qualify"
|
||||||
|
case db.PlayoffFormat10to15:
|
||||||
|
return "Top 8 qualify"
|
||||||
|
default:
|
||||||
|
return string(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bracketConnection represents a line to draw between two series cards
|
||||||
|
type bracketConnection struct {
|
||||||
|
From int `json:"from"`
|
||||||
|
To int `json:"to"`
|
||||||
|
Type string `json:"type"` // "winner" or "loser"
|
||||||
|
ToSide string `json:"toSide,omitempty"` // "left" or "right" — enters side of dest card
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectionsJSON returns a JSON string of connections for the bracket overlay JS.
|
||||||
|
// Connections are derived from the series advancement links stored in the DB.
|
||||||
|
// For the 10-15 format, QF winner lines enter PF cards from the side.
|
||||||
|
func connectionsJSON(series []*db.PlayoffSeries) string {
|
||||||
|
// Build a lookup of series ID → series for resolving advancement targets
|
||||||
|
byID := make(map[int]*db.PlayoffSeries, len(series))
|
||||||
|
for _, s := range series {
|
||||||
|
byID[s.ID] = s
|
||||||
|
}
|
||||||
|
|
||||||
|
var conns []bracketConnection
|
||||||
|
for _, s := range series {
|
||||||
|
if s.WinnerNextID != nil {
|
||||||
|
if target, ok := byID[*s.WinnerNextID]; ok {
|
||||||
|
conn := bracketConnection{
|
||||||
|
From: s.SeriesNumber,
|
||||||
|
To: target.SeriesNumber,
|
||||||
|
Type: "winner",
|
||||||
|
}
|
||||||
|
// QF winners enter PF cards from the side in the 10-15 format
|
||||||
|
if s.Round == "qualifying_final" && target.Round == "preliminary_final" {
|
||||||
|
if s.SeriesNumber == 1 {
|
||||||
|
conn.ToSide = "left"
|
||||||
|
} else if s.SeriesNumber == 2 {
|
||||||
|
conn.ToSide = "right"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conns = append(conns, conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.LoserNextID != nil {
|
||||||
|
if target, ok := byID[*s.LoserNextID]; ok {
|
||||||
|
conns = append(conns, bracketConnection{
|
||||||
|
From: s.SeriesNumber,
|
||||||
|
To: target.SeriesNumber,
|
||||||
|
Type: "loser",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(conns)
|
||||||
|
if err != nil {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -1,15 +1,116 @@
|
|||||||
package seasonsview
|
package seasonsview
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
templ SeasonLeagueFinalsPage(season *db.Season, league *db.League) {
|
templ SeasonLeagueFinalsPage(
|
||||||
|
season *db.Season,
|
||||||
|
league *db.League,
|
||||||
|
bracket *db.PlayoffBracket,
|
||||||
|
topGoals []*db.LeagueTopGoalScorer,
|
||||||
|
topAssists []*db.LeagueTopAssister,
|
||||||
|
topSaves []*db.LeagueTopSaver,
|
||||||
|
allStats []*db.LeaguePlayerStats,
|
||||||
|
) {
|
||||||
@SeasonLeagueLayout("finals", season, league) {
|
@SeasonLeagueLayout("finals", season, league) {
|
||||||
@SeasonLeagueFinals()
|
@SeasonLeagueFinals(season, league, bracket, topGoals, topAssists, topSaves, allStats)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SeasonLeagueFinals() {
|
templ SeasonLeagueFinals(
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
season *db.Season,
|
||||||
<p class="text-subtext0 text-lg">Coming Soon...</p>
|
league *db.League,
|
||||||
|
bracket *db.PlayoffBracket,
|
||||||
|
topGoals []*db.LeagueTopGoalScorer,
|
||||||
|
topAssists []*db.LeagueTopAssister,
|
||||||
|
topSaves []*db.LeagueTopSaver,
|
||||||
|
allStats []*db.LeaguePlayerStats,
|
||||||
|
) {
|
||||||
|
{{
|
||||||
|
permCache := contexts.Permissions(ctx)
|
||||||
|
canManagePlayoffs := permCache != nil && permCache.HasPermission(permissions.PlayoffsManage)
|
||||||
|
hasStats := len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 || len(allStats) > 0
|
||||||
|
}}
|
||||||
|
<div id="finals-content">
|
||||||
|
if bracket != nil {
|
||||||
|
@PlayoffBracketView(season, league, bracket)
|
||||||
|
<!-- Finals Stats Section -->
|
||||||
|
if hasStats {
|
||||||
|
<div class="mt-8">
|
||||||
|
@finalsStatsSection(season, league, topGoals, topAssists, topSaves, allStats)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else if canManagePlayoffs {
|
||||||
|
@finalsNotYetConfigured(season, league)
|
||||||
|
} else {
|
||||||
|
@finalsNotConfigured()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ finalsStatsSection(
|
||||||
|
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>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-yellow">★</span>
|
||||||
|
<h2 class="text-xl font-bold text-text">Finals Stats</h2>
|
||||||
|
</div>
|
||||||
|
<!-- Trophy Leaders -->
|
||||||
|
if len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 {
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-bold text-text text-center">Trophy Leaders</h3>
|
||||||
|
<div class="flex flex-col items-center gap-6 w-full">
|
||||||
|
<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>
|
||||||
|
@topSaversTable(season, league, topSaves)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- All Finals Stats -->
|
||||||
|
if len(allStats) > 0 {
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-bold text-text text-center">All Finals Stats</h3>
|
||||||
|
@allStatsTable(season, league, allStats)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ finalsNotYetConfigured(season *db.Season, league *db.League) {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<div class="mb-4">
|
||||||
|
<svg class="w-12 h-12 mx-auto text-subtext0 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 18.75h-9m9 0a3 3 0 013 3h-15a3 3 0 013-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 01-.982-3.172M9.497 14.25a7.454 7.454 0 00.981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 007.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M18.75 4.236c.982.143 1.954.317 2.916.52A6.003 6.003 0 0016.27 9.728M18.75 4.236V4.5c0 2.108-.966 3.99-2.48 5.228m0 0a6.003 6.003 0 01-2.77.836 6.003 6.003 0 01-2.77-.836"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-text text-lg font-semibold mb-2">No Finals Configured</p>
|
||||||
|
<p class="text-subtext0 mb-6">
|
||||||
|
Set up the playoff bracket for this league.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
hx-get={ fmt.Sprintf("/seasons/%s/leagues/%s/finals/setup", season.ShortName, league.ShortName) }
|
||||||
|
hx-target="#finals-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="px-6 py-3 bg-blue hover:bg-blue/80 text-mantle rounded-lg font-semibold transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Begin Finals
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ finalsNotConfigured() {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No finals configured for this league.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,21 +159,35 @@ templ fixtureListItem(fixture *db.Fixture, sched *db.FixtureSchedule, hasSchedul
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
if hasResult {
|
if hasResult {
|
||||||
<span class="flex items-center gap-2">
|
if res.IsForfeit {
|
||||||
if res.Winner == "home" {
|
<span class="flex items-center gap-2">
|
||||||
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
if res.ForfeitType != nil && *res.ForfeitType == "mutual" {
|
||||||
<span class="text-xs text-subtext0">–</span>
|
<span class="px-2 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||||
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
|
Mutual Forfeit
|
||||||
} else if res.Winner == "away" {
|
</span>
|
||||||
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
|
} else {
|
||||||
<span class="text-xs text-subtext0">–</span>
|
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
|
||||||
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
Forfeit
|
||||||
} else {
|
</span>
|
||||||
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
}
|
||||||
<span class="text-xs text-subtext0">–</span>
|
</span>
|
||||||
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
} else {
|
||||||
}
|
<span class="flex items-center gap-2">
|
||||||
</span>
|
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 {
|
} else if hasSchedule && sched.ScheduledTime != nil {
|
||||||
<span class="text-xs text-green font-medium">
|
<span class="text-xs text-green font-medium">
|
||||||
@localtime(sched.ScheduledTime, "short")
|
@localtime(sched.ScheduledTime, "short")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package seasonsview
|
|||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
templ SeasonLeagueFreeAgentsPage(season *db.Season, league *db.League, freeAgents []*db.SeasonLeagueFreeAgent, availablePlayers []*db.Player) {
|
templ SeasonLeagueFreeAgentsPage(season *db.Season, league *db.League, freeAgents []*db.SeasonLeagueFreeAgent, availablePlayers []*db.Player) {
|
||||||
@@ -44,7 +45,6 @@ templ SeasonLeagueFreeAgents(season *db.Season, league *db.League, freeAgents []
|
|||||||
<thead class="bg-mantle border-b border-surface1">
|
<thead class="bg-mantle border-b border-surface1">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Player</th>
|
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Player</th>
|
||||||
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Registered By</th>
|
|
||||||
if canRemove {
|
if canRemove {
|
||||||
<th class="px-4 py-3 text-right text-sm font-semibold text-text">Actions</th>
|
<th class="px-4 py-3 text-right text-sm font-semibold text-text">Actions</th>
|
||||||
}
|
}
|
||||||
@@ -53,19 +53,14 @@ templ SeasonLeagueFreeAgents(season *db.Season, league *db.League, freeAgents []
|
|||||||
<tbody class="divide-y divide-surface1">
|
<tbody class="divide-y divide-surface1">
|
||||||
for _, fa := range freeAgents {
|
for _, fa := range freeAgents {
|
||||||
<tr class="hover:bg-surface1 transition-colors">
|
<tr class="hover:bg-surface1 transition-colors">
|
||||||
<td class="px-4 py-3 text-sm text-text">
|
<td class="px-4 py-3 text-sm">
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
{ fa.Player.DisplayName() }
|
@links.PlayerLink(fa.Player)
|
||||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||||
FREE AGENT
|
FREE AGENT
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-subtext0">
|
|
||||||
if fa.RegisteredBy != nil {
|
|
||||||
{ fa.RegisteredBy.Username }
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
if canRemove {
|
if canRemove {
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -1,15 +1,327 @@
|
|||||||
package seasonsview
|
package seasonsview
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
templ SeasonLeagueStatsPage(season *db.Season, league *db.League) {
|
templ SeasonLeagueStatsPage(
|
||||||
|
season *db.Season,
|
||||||
|
league *db.League,
|
||||||
|
topGoals []*db.LeagueTopGoalScorer,
|
||||||
|
topAssists []*db.LeagueTopAssister,
|
||||||
|
topSaves []*db.LeagueTopSaver,
|
||||||
|
allStats []*db.LeaguePlayerStats,
|
||||||
|
) {
|
||||||
@SeasonLeagueLayout("stats", season, league) {
|
@SeasonLeagueLayout("stats", season, league) {
|
||||||
@SeasonLeagueStats()
|
@SeasonLeagueStats(season, league, topGoals, topAssists, topSaves, allStats)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SeasonLeagueStats() {
|
templ SeasonLeagueStats(
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
season *db.Season,
|
||||||
<p class="text-subtext0 text-lg">Coming Soon...</p>
|
league *db.League,
|
||||||
|
topGoals []*db.LeagueTopGoalScorer,
|
||||||
|
topAssists []*db.LeagueTopAssister,
|
||||||
|
topSaves []*db.LeagueTopSaver,
|
||||||
|
allStats []*db.LeaguePlayerStats,
|
||||||
|
) {
|
||||||
|
<script src="/static/js/sortable-table.js"></script>
|
||||||
|
if len(topGoals) == 0 && len(topAssists) == 0 && len(topSaves) == 0 && len(allStats) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No stats available yet.</p>
|
||||||
|
<p class="text-subtext1 text-sm mt-2">Player statistics will appear here once games are finalized.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Trophy Leaders Section -->
|
||||||
|
if len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 {
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h2 class="text-xl font-bold text-text text-center">Trophy Leaders</h2>
|
||||||
|
<!-- Triangle layout: two side-by-side on wide screens, saves centered below -->
|
||||||
|
<div class="flex flex-col items-center gap-6 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 ↓</span>
|
||||||
|
<span>PP ↑</span>
|
||||||
|
<span>SH ↑</span>
|
||||||
|
</div>
|
||||||
|
if len(goals) == 0 {
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<p class="text-subtext0 text-sm">No goal data available yet.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<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>
|
</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 ↓</span>
|
||||||
|
<span>PP ↑</span>
|
||||||
|
<span>PA ↓</span>
|
||||||
|
</div>
|
||||||
|
if len(assists) == 0 {
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<p class="text-subtext0 text-sm">No assist data available yet.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<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 ↓</span>
|
||||||
|
<span>PP ↑</span>
|
||||||
|
<span>BLK ↓</span>
|
||||||
|
</div>
|
||||||
|
if len(saves) == 0 {
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<p class="text-subtext0 text-sm">No save data available yet.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package seasonsview
|
package seasonsview
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
templ SeasonLeagueTablePage(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) {
|
templ SeasonLeagueTablePage(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) {
|
||||||
@SeasonLeagueLayout("table", season, league) {
|
@SeasonLeagueLayout("table", season, league) {
|
||||||
@SeasonLeagueTable(leaderboard)
|
@SeasonLeagueTable(season, league, leaderboard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) {
|
templ SeasonLeagueTable(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) {
|
||||||
if len(leaderboard) == 0 {
|
if len(leaderboard) == 0 {
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
<p class="text-subtext0 text-lg">No teams in this league yet.</p>
|
<p class="text-subtext0 text-lg">No teams in this league yet.</p>
|
||||||
@@ -43,7 +44,7 @@ templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-surface1">
|
<tbody class="divide-y divide-surface1">
|
||||||
for _, entry := range leaderboard {
|
for _, entry := range leaderboard {
|
||||||
@leaderboardRow(entry)
|
@leaderboardRow(entry, season, league)
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -52,7 +53,7 @@ templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ leaderboardRow(entry *db.LeaderboardEntry) {
|
templ leaderboardRow(entry *db.LeaderboardEntry, season *db.Season, league *db.League) {
|
||||||
{{
|
{{
|
||||||
r := entry.Record
|
r := entry.Record
|
||||||
goalDiff := r.GoalsFor - r.GoalsAgainst
|
goalDiff := r.GoalsFor - r.GoalsAgainst
|
||||||
@@ -68,15 +69,7 @@ templ leaderboardRow(entry *db.LeaderboardEntry) {
|
|||||||
{ fmt.Sprint(entry.Position) }
|
{ fmt.Sprint(entry.Position) }
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex items-center gap-2">
|
@links.TeamLinkInSeason(entry.Team, season, league)
|
||||||
if entry.Team.Color != "" {
|
|
||||||
<span
|
|
||||||
class="w-3 h-3 rounded-full shrink-0"
|
|
||||||
style={ "background-color: " + templ.SafeCSS(entry.Team.Color) }
|
|
||||||
></span>
|
|
||||||
}
|
|
||||||
<span class="text-sm font-medium text-text">{ entry.Team.Name }</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-3 text-center text-sm text-subtext0">
|
<td class="px-3 py-3 text-center text-sm text-subtext0">
|
||||||
{ fmt.Sprint(r.Played) }
|
{ fmt.Sprint(r.Played) }
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import "git.haelnorr.com/h/oslstats/internal/db"
|
|||||||
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
import "sort"
|
import "sort"
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult, record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) {
|
templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult, record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats, position int, totalTeams int) {
|
||||||
{{
|
{{
|
||||||
team := twr.Team
|
team := twr.Team
|
||||||
season := twr.Season
|
season := twr.Season
|
||||||
@@ -42,25 +43,68 @@ templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<div class="flex items-center gap-2">
|
||||||
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName)) }
|
<a
|
||||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
href={ templ.SafeURL(fmt.Sprintf("/teams/%d", team.ID)) }
|
||||||
bg-surface1 hover:bg-surface2 text-text transition"
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
>
|
bg-surface0 border border-surface1 hover:bg-surface1 text-subtext0 hover:text-text transition text-sm"
|
||||||
Back to Teams
|
>
|
||||||
</a>
|
View All Seasons
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName)) }
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
|
bg-surface1 hover:bg-surface2 text-text transition"
|
||||||
|
>
|
||||||
|
Back to Teams
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="bg-crust p-6">
|
<div class="bg-crust p-6">
|
||||||
<!-- Top row: Roster (left) + Fixtures (right) -->
|
{{
|
||||||
|
// Split fixtures into upcoming and completed
|
||||||
|
var upcoming []*db.Fixture
|
||||||
|
var completed []*db.Fixture
|
||||||
|
for _, f := range fixtures {
|
||||||
|
if _, hasResult := resultMap[f.ID]; hasResult {
|
||||||
|
completed = append(completed, f)
|
||||||
|
} else {
|
||||||
|
upcoming = append(upcoming, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort completed by scheduled time descending (most recent first)
|
||||||
|
sort.Slice(completed, func(i, j int) bool {
|
||||||
|
ti := time.Time{}
|
||||||
|
tj := time.Time{}
|
||||||
|
if si, ok := scheduleMap[completed[i].ID]; ok && si.ScheduledTime != nil {
|
||||||
|
ti = *si.ScheduledTime
|
||||||
|
}
|
||||||
|
if sj, ok := scheduleMap[completed[j].ID]; ok && sj.ScheduledTime != nil {
|
||||||
|
tj = *sj.ScheduledTime
|
||||||
|
}
|
||||||
|
return ti.After(tj)
|
||||||
|
})
|
||||||
|
// Limit to 5 most recent results
|
||||||
|
recentResults := completed
|
||||||
|
if len(recentResults) > 5 {
|
||||||
|
recentResults = recentResults[:5]
|
||||||
|
}
|
||||||
|
}}
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Top Left: Team Standing -->
|
||||||
|
@teamRecordCard(record, position, totalTeams)
|
||||||
|
<!-- Top Right: Results -->
|
||||||
|
@teamResultsSection(twr.Team, recentResults, resultMap)
|
||||||
|
<!-- Bottom Left: Roster -->
|
||||||
@TeamRosterSection(twr, available)
|
@TeamRosterSection(twr, available)
|
||||||
@teamFixturesPane(twr.Team, fixtures, scheduleMap, resultMap)
|
<!-- Bottom Right: Upcoming -->
|
||||||
|
@teamUpcomingSection(twr.Team, upcoming, scheduleMap)
|
||||||
</div>
|
</div>
|
||||||
<!-- Stats below both -->
|
<!-- Player Stats (full width) -->
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
@teamStatsSection(record, playerStats)
|
@playerStatsSection(playerStats)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +155,9 @@ templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) {
|
|||||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||||
if twr.Manager != nil {
|
if twr.Manager != nil {
|
||||||
<div class="px-4 py-3 flex items-center justify-between">
|
<div class="px-4 py-3 flex items-center justify-between">
|
||||||
<span class="text-text font-medium">{ twr.Manager.Name }</span>
|
<span class="font-medium">
|
||||||
|
@links.PlayerLink(twr.Manager)
|
||||||
|
</span>
|
||||||
<span class="text-xs px-2 py-0.5 bg-yellow/20 text-yellow rounded font-medium">
|
<span class="text-xs px-2 py-0.5 bg-yellow/20 text-yellow rounded font-medium">
|
||||||
★ Manager
|
★ Manager
|
||||||
</span>
|
</span>
|
||||||
@@ -119,7 +165,7 @@ templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) {
|
|||||||
}
|
}
|
||||||
for _, player := range rosterPlayers {
|
for _, player := range rosterPlayers {
|
||||||
<div class="px-4 py-3">
|
<div class="px-4 py-3">
|
||||||
<span class="text-text">{ player.Name }</span>
|
@links.PlayerLink(player)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -396,68 +442,45 @@ templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPl
|
|||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) {
|
templ teamResultsSection(team *db.Team, recentResults []*db.Fixture, resultMap map[int]*db.FixtureResult) {
|
||||||
{{
|
<section>
|
||||||
// Split fixtures into upcoming and completed
|
<div class="flex justify-between items-center mb-4">
|
||||||
var upcoming []*db.Fixture
|
<h2 class="text-2xl font-bold text-text">
|
||||||
var completed []*db.Fixture
|
Results
|
||||||
for _, f := range fixtures {
|
<span class="text-sm font-normal text-subtext0">(last 5)</span>
|
||||||
if _, hasResult := resultMap[f.ID]; hasResult {
|
</h2>
|
||||||
completed = append(completed, f)
|
|
||||||
} else {
|
|
||||||
upcoming = append(upcoming, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Sort completed by scheduled time descending (most recent first)
|
|
||||||
sort.Slice(completed, func(i, j int) bool {
|
|
||||||
ti := time.Time{}
|
|
||||||
tj := time.Time{}
|
|
||||||
if si, ok := scheduleMap[completed[i].ID]; ok && si.ScheduledTime != nil {
|
|
||||||
ti = *si.ScheduledTime
|
|
||||||
}
|
|
||||||
if sj, ok := scheduleMap[completed[j].ID]; ok && sj.ScheduledTime != nil {
|
|
||||||
tj = *sj.ScheduledTime
|
|
||||||
}
|
|
||||||
return ti.After(tj)
|
|
||||||
})
|
|
||||||
// Limit to 5 most recent results
|
|
||||||
recentResults := completed
|
|
||||||
if len(recentResults) > 5 {
|
|
||||||
recentResults = recentResults[:5]
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
<section class="space-y-6">
|
|
||||||
<!-- Results -->
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-text mb-4">Results</h2>
|
|
||||||
if len(recentResults) == 0 {
|
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
|
||||||
<p class="text-subtext0 text-lg">No results yet.</p>
|
|
||||||
<p class="text-subtext1 text-sm mt-2">Match results will appear here once games are played.</p>
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
|
||||||
for _, fixture := range recentResults {
|
|
||||||
@teamResultRow(team, fixture, resultMap)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Upcoming -->
|
if len(recentResults) == 0 {
|
||||||
<div>
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
<h2 class="text-2xl font-bold text-text mb-4">Upcoming</h2>
|
<p class="text-subtext0 text-lg">No results yet.</p>
|
||||||
if len(upcoming) == 0 {
|
<p class="text-subtext1 text-sm mt-2">Match results will appear here once games are played.</p>
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
</div>
|
||||||
<p class="text-subtext0 text-lg">No upcoming fixtures.</p>
|
} else {
|
||||||
</div>
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||||
} else {
|
for _, fixture := range recentResults {
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
@teamResultRow(team, fixture, resultMap)
|
||||||
for _, fixture := range upcoming {
|
}
|
||||||
@teamFixtureRow(team, fixture, scheduleMap)
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ teamUpcomingSection(team *db.Team, upcoming []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
|
||||||
|
<section>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-2xl font-bold text-text">Upcoming</h2>
|
||||||
</div>
|
</div>
|
||||||
|
if len(upcoming) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No upcoming fixtures.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||||
|
for _, fixture := range upcoming {
|
||||||
|
@teamFixtureRow(team, fixture, scheduleMap)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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")
|
won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away")
|
||||||
lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home")
|
lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home")
|
||||||
_ = lost
|
_ = lost
|
||||||
|
isForfeit := res.IsForfeit
|
||||||
|
isMutualForfeit := isForfeit && res.ForfeitType != nil && *res.ForfeitType == "mutual"
|
||||||
}}
|
}}
|
||||||
<a
|
<a
|
||||||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
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"
|
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">
|
<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>
|
<span class="text-xs font-bold px-2 py-0.5 bg-green/20 text-green rounded shrink-0">W</span>
|
||||||
} else if lost {
|
} else if lost {
|
||||||
<span class="text-xs font-bold px-2 py-0.5 bg-red/20 text-red rounded shrink-0">L</span>
|
<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 }
|
{ opponent }
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="flex items-center gap-2 shrink-0">
|
if isForfeit {
|
||||||
if res.Winner == "home" {
|
<span class="flex items-center gap-2 shrink-0">
|
||||||
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
if isMutualForfeit {
|
||||||
<span class="text-xs text-subtext0">–</span>
|
<span class="px-2 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||||
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
|
Mutual Forfeit
|
||||||
} else if res.Winner == "away" {
|
</span>
|
||||||
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
|
} else {
|
||||||
<span class="text-xs text-subtext0">–</span>
|
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
|
||||||
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
Forfeit
|
||||||
} else {
|
</span>
|
||||||
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
}
|
||||||
<span class="text-xs text-subtext0">–</span>
|
</span>
|
||||||
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
} else {
|
||||||
}
|
<span class="flex items-center gap-2 shrink-0">
|
||||||
</span>
|
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>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ teamStatsSection(record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) {
|
templ teamRecordCard(record *db.TeamRecord, position int, totalTeams int) {
|
||||||
<section>
|
<section>
|
||||||
<div class="mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-2xl font-bold text-text">Stats</h2>
|
<h2 class="text-2xl font-bold text-text">Standing</h2>
|
||||||
</div>
|
</div>
|
||||||
if record.Played == 0 {
|
if record.Played == 0 {
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
<p class="text-subtext0 text-lg">No stats yet.</p>
|
<p class="text-subtext0 text-lg">No games played yet.</p>
|
||||||
<p class="text-subtext1 text-sm mt-2">Team statistics will appear here once games are played.</p>
|
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<!-- Team Record Summary -->
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden mb-4">
|
<!-- Position & Points Header -->
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 divide-x divide-surface1">
|
<div class="flex items-center justify-between px-6 py-5 border-b border-surface1">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-4xl font-bold text-text">{ ordinal(position) }</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-subtext0 uppercase font-medium">Position</p>
|
||||||
|
<p class="text-sm text-subtext1">of { fmt.Sprint(totalTeams) } teams</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-xs text-subtext0 uppercase font-medium">Points</p>
|
||||||
|
<p class="text-3xl font-bold text-blue">{ fmt.Sprint(record.Points) }</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Record Grid -->
|
||||||
|
<div class="grid grid-cols-4 divide-x divide-surface1">
|
||||||
|
@statCell("W", fmt.Sprint(record.Wins), "text-green")
|
||||||
|
@statCell("OTW", fmt.Sprint(record.OvertimeWins), "text-teal")
|
||||||
|
@statCell("OTL", fmt.Sprint(record.OvertimeLosses), "text-peach")
|
||||||
|
@statCell("L", fmt.Sprint(record.Losses), "text-red")
|
||||||
|
</div>
|
||||||
|
<!-- Goals Row -->
|
||||||
|
<div class="grid grid-cols-3 divide-x divide-surface1 border-t border-surface1">
|
||||||
@statCell("Played", fmt.Sprint(record.Played), "")
|
@statCell("Played", fmt.Sprint(record.Played), "")
|
||||||
@statCell("Record", fmt.Sprintf("%d-%d-%d", record.Wins, record.Losses, record.Draws), "")
|
|
||||||
@statCell("Wins", fmt.Sprint(record.Wins), "text-green")
|
|
||||||
@statCell("Losses", fmt.Sprint(record.Losses), "text-red")
|
|
||||||
@statCell("GF", fmt.Sprint(record.GoalsFor), "")
|
@statCell("GF", fmt.Sprint(record.GoalsFor), "")
|
||||||
@statCell("GA", fmt.Sprint(record.GoalsAgainst), "")
|
@statCell("GA", fmt.Sprint(record.GoalsAgainst), "")
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Player Stats Leaderboard -->
|
}
|
||||||
if len(playerStats) > 0 {
|
</section>
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
}
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full">
|
templ playerStatsSection(playerStats []*db.AggregatedPlayerStats) {
|
||||||
<thead class="bg-mantle border-b border-surface1">
|
<section>
|
||||||
<tr>
|
<div class="flex justify-between items-center mb-4">
|
||||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
<h2 class="text-2xl font-bold text-text">Player Stats</h2>
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Games Played">GP</th>
|
</div>
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
if len(playerStats) == 0 {
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
<p class="text-subtext0 text-lg">No player stats yet.</p>
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
<p class="text-subtext1 text-sm mt-2">Player statistics will appear here once games are played.</p>
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
|
</div>
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th>
|
} else {
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th>
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-mantle border-b border-surface1">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Games Played">GP</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface1">
|
||||||
|
for _, ps := range playerStats {
|
||||||
|
<tr class="hover:bg-surface1 transition-colors">
|
||||||
|
<td class="px-3 py-2 text-sm">
|
||||||
|
@links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName)
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.GamesPlayed) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.PeriodsPlayed) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ fmt.Sprint(ps.Score) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Goals) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Assists) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Saves) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Shots) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Blocks) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Passes) }</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
}
|
||||||
<tbody class="divide-y divide-surface1">
|
</tbody>
|
||||||
for _, ps := range playerStats {
|
</table>
|
||||||
<tr class="hover:bg-surface1 transition-colors">
|
|
||||||
<td class="px-3 py-2 text-sm text-text">{ ps.PlayerName }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.GamesPlayed) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ fmt.Sprint(ps.Score) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Goals) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Assists) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Saves) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Shots) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Blocks) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Passes) }</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|||||||
660
internal/view/seasonsview/series_detail.templ
Normal file
660
internal/view/seasonsview/series_detail.templ
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
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/baseview"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
|
import "fmt"
|
||||||
|
import "sort"
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// seriesTeamName returns a display name for a team in the series, or "TBD" if nil
|
||||||
|
func seriesTeamName(team *db.Team) string {
|
||||||
|
if team == nil {
|
||||||
|
return "TBD"
|
||||||
|
}
|
||||||
|
return team.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesTeamShortName returns a short name for a team in the series, or "TBD" if nil
|
||||||
|
func seriesTeamShortName(team *db.Team) string {
|
||||||
|
if team == nil {
|
||||||
|
return "TBD"
|
||||||
|
}
|
||||||
|
return team.ShortName
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundDisplayName converts a round slug to a human-readable name
|
||||||
|
func roundDisplayName(round string) string {
|
||||||
|
switch round {
|
||||||
|
case "upper_bracket":
|
||||||
|
return "Upper Bracket"
|
||||||
|
case "lower_bracket":
|
||||||
|
return "Lower Bracket"
|
||||||
|
case "upper_final":
|
||||||
|
return "Upper Final"
|
||||||
|
case "lower_final":
|
||||||
|
return "Lower Final"
|
||||||
|
case "quarter_final":
|
||||||
|
return "Quarter Final"
|
||||||
|
case "semi_final":
|
||||||
|
return "Semi Final"
|
||||||
|
case "elimination_final":
|
||||||
|
return "Elimination Final"
|
||||||
|
case "qualifying_final":
|
||||||
|
return "Qualifying Final"
|
||||||
|
case "preliminary_final":
|
||||||
|
return "Preliminary Final"
|
||||||
|
case "third_place":
|
||||||
|
return "Third Place Playoff"
|
||||||
|
case "grand_final":
|
||||||
|
return "Grand Final"
|
||||||
|
default:
|
||||||
|
return strings.ReplaceAll(round, "_", " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesDetailLayout renders the series detail page layout with header and
|
||||||
|
// tab navigation. Tab content is rendered as children.
|
||||||
|
templ SeriesDetailLayout(activeTab string, series *db.PlayoffSeries, schedule *db.PlayoffSeriesSchedule) {
|
||||||
|
{{
|
||||||
|
backURL := fmt.Sprintf("/seasons/%s/leagues/%s/finals",
|
||||||
|
series.Bracket.Season.ShortName, series.Bracket.League.ShortName)
|
||||||
|
isCompleted := series.Status == db.SeriesStatusCompleted
|
||||||
|
team1Name := seriesTeamName(series.Team1)
|
||||||
|
team2Name := seriesTeamName(series.Team2)
|
||||||
|
boLabel := fmt.Sprintf("BO%d", series.MatchesToWin*2-1)
|
||||||
|
}}
|
||||||
|
@baseview.Layout(fmt.Sprintf("%s — %s vs %s", series.Label, team1Name, team2Name)) {
|
||||||
|
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
||||||
|
<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-4 mb-2">
|
||||||
|
<h1 class="text-3xl font-bold text-text">
|
||||||
|
{ team1Name }
|
||||||
|
<span class="text-subtext0 font-normal">vs</span>
|
||||||
|
{ team2Name }
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||||
|
{ series.Label }
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-0.5 bg-surface1 text-subtext0 rounded text-xs font-mono">
|
||||||
|
{ boLabel }
|
||||||
|
</span>
|
||||||
|
if series.Team1Seed != nil || series.Team2Seed != nil {
|
||||||
|
<span class="px-2 py-0.5 bg-surface1 text-subtext0 rounded text-xs font-mono">
|
||||||
|
if series.Team1Seed != nil && series.Team2Seed != nil {
|
||||||
|
Seed { fmt.Sprint(*series.Team1Seed) } vs { fmt.Sprint(*series.Team2Seed) }
|
||||||
|
} else if series.Team1Seed != nil {
|
||||||
|
Seed { fmt.Sprint(*series.Team1Seed) }
|
||||||
|
} else if series.Team2Seed != nil {
|
||||||
|
Seed { fmt.Sprint(*series.Team2Seed) }
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="text-subtext1 text-sm">
|
||||||
|
{ series.Bracket.Season.Name } — { series.Bracket.League.Name }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(backURL) }
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
|
bg-surface1 hover:bg-surface2 text-text transition"
|
||||||
|
>
|
||||||
|
Back to Bracket
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<nav class="bg-surface0 border-b border-surface1" data-tab-nav="series-detail-content">
|
||||||
|
<ul class="flex flex-wrap">
|
||||||
|
@seriesTabItem("overview", "Overview", activeTab, series)
|
||||||
|
if isCompleted {
|
||||||
|
@seriesTabItem("analysis", "Match Analysis", activeTab, series)
|
||||||
|
} else {
|
||||||
|
@seriesTabItem("preview", "Match Preview", activeTab, series)
|
||||||
|
@seriesTabItem("scheduling", "Schedule", activeTab, series)
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<!-- Content Area -->
|
||||||
|
<main id="series-detail-content">
|
||||||
|
{ children... }
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/tabs.js" defer></script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesTabItem(section string, label string, activeTab string, series *db.PlayoffSeries) {
|
||||||
|
{{
|
||||||
|
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("/series/%d/%s", series.ID, section)
|
||||||
|
}}
|
||||||
|
<li class="inline-block">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(url) }
|
||||||
|
hx-post={ url }
|
||||||
|
hx-target="#series-detail-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 SeriesDetailOverviewPage(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
currentSchedule *db.PlayoffSeriesSchedule,
|
||||||
|
canSchedule bool,
|
||||||
|
userTeamID int,
|
||||||
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
|
) {
|
||||||
|
@SeriesDetailLayout("overview", series, currentSchedule) {
|
||||||
|
@SeriesDetailOverviewContent(series, currentSchedule, canSchedule, userTeamID, rosters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SeriesDetailPreviewPage(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
currentSchedule *db.PlayoffSeriesSchedule,
|
||||||
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
|
previewData *db.MatchPreviewData,
|
||||||
|
) {
|
||||||
|
@SeriesDetailLayout("preview", series, currentSchedule) {
|
||||||
|
@SeriesDetailPreviewContent(series, rosters, previewData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SeriesDetailAnalysisPage(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
currentSchedule *db.PlayoffSeriesSchedule,
|
||||||
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
|
previewData *db.MatchPreviewData,
|
||||||
|
) {
|
||||||
|
@SeriesDetailLayout("analysis", series, currentSchedule) {
|
||||||
|
@SeriesDetailAnalysisContent(series, rosters, previewData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SeriesDetailSchedulePage(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
currentSchedule *db.PlayoffSeriesSchedule,
|
||||||
|
history []*db.PlayoffSeriesSchedule,
|
||||||
|
canSchedule bool,
|
||||||
|
userTeamID int,
|
||||||
|
) {
|
||||||
|
@SeriesDetailLayout("scheduling", series, currentSchedule) {
|
||||||
|
@SeriesDetailScheduleContent(series, currentSchedule, history, canSchedule, userTeamID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Tab content components (for POST requests / HTMX swaps) ====================
|
||||||
|
|
||||||
|
templ SeriesDetailOverviewContent(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
currentSchedule *db.PlayoffSeriesSchedule,
|
||||||
|
canSchedule bool,
|
||||||
|
userTeamID int,
|
||||||
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
|
) {
|
||||||
|
{{
|
||||||
|
permCache := contexts.Permissions(ctx)
|
||||||
|
canManage := permCache.HasPermission(permissions.PlayoffsManage)
|
||||||
|
}}
|
||||||
|
@seriesOverviewTab(series, currentSchedule, rosters, canSchedule, canManage, userTeamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SeriesDetailPreviewContent(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
|
previewData *db.MatchPreviewData,
|
||||||
|
) {
|
||||||
|
@seriesMatchPreviewTab(series, rosters, previewData)
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SeriesDetailAnalysisContent(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
|
previewData *db.MatchPreviewData,
|
||||||
|
) {
|
||||||
|
@seriesMatchAnalysisTab(series, rosters, previewData)
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SeriesDetailScheduleContent(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
currentSchedule *db.PlayoffSeriesSchedule,
|
||||||
|
history []*db.PlayoffSeriesSchedule,
|
||||||
|
canSchedule bool,
|
||||||
|
userTeamID int,
|
||||||
|
) {
|
||||||
|
{{
|
||||||
|
permCache := contexts.Permissions(ctx)
|
||||||
|
canManage := permCache.HasPermission(permissions.PlayoffsManage)
|
||||||
|
}}
|
||||||
|
@seriesScheduleTab(series, currentSchedule, history, canSchedule, canManage, userTeamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Overview Tab ====================
|
||||||
|
templ seriesOverviewTab(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
currentSchedule *db.PlayoffSeriesSchedule,
|
||||||
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
|
canSchedule bool,
|
||||||
|
canManage bool,
|
||||||
|
userTeamID int,
|
||||||
|
) {
|
||||||
|
{{
|
||||||
|
isCompleted := series.Status == db.SeriesStatusCompleted
|
||||||
|
isBye := series.Status == db.SeriesStatusBye
|
||||||
|
bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil
|
||||||
|
showUploadPrompt := canManage && !isCompleted && !isBye && bothTeamsAssigned
|
||||||
|
}}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Series Score + Schedule Row -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
@seriesScoreDisplay(series)
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
@seriesScheduleSummary(series, currentSchedule)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Prompt (for admins when series is in progress) -->
|
||||||
|
if showUploadPrompt {
|
||||||
|
@seriesUploadPrompt(series)
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Match List -->
|
||||||
|
if len(series.Matches) > 0 {
|
||||||
|
@seriesMatchList(series)
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Series Context -->
|
||||||
|
@seriesContextCard(series)
|
||||||
|
|
||||||
|
<!-- Team Rosters -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
if series.Team1 != nil {
|
||||||
|
@seriesTeamSection(series.Team1, rosters["team1"], series.Bracket.Season, series.Bracket.League)
|
||||||
|
}
|
||||||
|
if series.Team2 != nil {
|
||||||
|
@seriesTeamSection(series.Team2, rosters["team2"], series.Bracket.Season, series.Bracket.League)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesUploadPrompt(series *db.PlayoffSeries) {
|
||||||
|
{{
|
||||||
|
// Check if there are pending results waiting for review
|
||||||
|
hasPendingMatches := false
|
||||||
|
for _, match := range series.Matches {
|
||||||
|
if match.FixtureID != nil && match.Status == "pending" {
|
||||||
|
hasPendingMatches = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||||||
|
if hasPendingMatches {
|
||||||
|
<div class="text-4xl mb-3">📋</div>
|
||||||
|
<p class="text-lg text-text font-medium mb-2">Results Pending Review</p>
|
||||||
|
<p class="text-sm text-subtext1 mb-4">Uploaded results are waiting to be reviewed and finalized.</p>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/review", series.ID)) }
|
||||||
|
class="inline-block px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Review Results
|
||||||
|
</a>
|
||||||
|
} else {
|
||||||
|
<div class="text-4xl mb-3">📋</div>
|
||||||
|
<p class="text-lg text-text font-medium mb-2">No Results Uploaded</p>
|
||||||
|
<p class="text-sm text-subtext1 mb-4">Upload match log files to record the series results.</p>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/upload", series.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>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesScoreDisplay(series *db.PlayoffSeries) {
|
||||||
|
{{
|
||||||
|
isCompleted := series.Status == db.SeriesStatusCompleted
|
||||||
|
team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID
|
||||||
|
team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID
|
||||||
|
isBye := series.Status == db.SeriesStatusBye
|
||||||
|
}}
|
||||||
|
<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">Series Score</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@seriesStatusBadge(series.Status)
|
||||||
|
@seriesFormatBadge(series.MatchesToWin)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
if isBye {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<p class="text-lg text-subtext0">Bye — team advances automatically</p>
|
||||||
|
</div>
|
||||||
|
} else if series.Team1 == nil && series.Team2 == nil {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<p class="text-lg text-subtext0">Teams not yet determined</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="flex items-center justify-center gap-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
if team1Won {
|
||||||
|
<span class="text-2xl">🏆</span>
|
||||||
|
}
|
||||||
|
if series.Team1 != nil && series.Team1.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",
|
||||||
|
series.Team1.Color, series.Team1.Color, series.Team1.Color) }
|
||||||
|
>
|
||||||
|
{ seriesTeamShortName(series.Team1) }
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||||||
|
{ seriesTeamShortName(series.Team1) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="text-4xl font-bold text-text">{ fmt.Sprint(series.Team1Wins) }</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span class="text-4xl text-subtext0 font-light leading-none">–</span>
|
||||||
|
if isCompleted {
|
||||||
|
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs font-semibold mt-1">
|
||||||
|
FINAL
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-4xl font-bold text-text">{ fmt.Sprint(series.Team2Wins) }</span>
|
||||||
|
if series.Team2 != nil && series.Team2.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",
|
||||||
|
series.Team2.Color, series.Team2.Color, series.Team2.Color) }
|
||||||
|
>
|
||||||
|
{ seriesTeamShortName(series.Team2) }
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||||||
|
{ seriesTeamShortName(series.Team2) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
if team2Won {
|
||||||
|
<span class="text-2xl">🏆</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesScheduleSummary(series *db.PlayoffSeries, schedule *db.PlayoffSeriesSchedule) {
|
||||||
|
{{
|
||||||
|
isCompleted := series.Status == db.SeriesStatusCompleted
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden h-full">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Schedule</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 flex flex-col justify-center h-[calc(100%-3rem)]">
|
||||||
|
if schedule == nil {
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-subtext1 text-sm">No time scheduled</p>
|
||||||
|
</div>
|
||||||
|
} else if schedule.Status == db.ScheduleStatusAccepted && schedule.ScheduledTime != nil {
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
if isCompleted {
|
||||||
|
<span class="px-2 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium">
|
||||||
|
Played
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
||||||
|
Confirmed
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<p class="text-text font-medium">
|
||||||
|
@localtime(schedule.ScheduledTime, "date")
|
||||||
|
</p>
|
||||||
|
<p class="text-text text-lg font-bold">
|
||||||
|
@localtime(schedule.ScheduledTime, "time")
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if schedule.Status == db.ScheduleStatusPending && schedule.ScheduledTime != nil {
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||||
|
Proposed
|
||||||
|
</span>
|
||||||
|
<p class="text-text font-medium">
|
||||||
|
@localtime(schedule.ScheduledTime, "date")
|
||||||
|
</p>
|
||||||
|
<p class="text-text text-lg font-bold">
|
||||||
|
@localtime(schedule.ScheduledTime, "time")
|
||||||
|
</p>
|
||||||
|
<p class="text-subtext1 text-xs">Awaiting confirmation</p>
|
||||||
|
</div>
|
||||||
|
} else if schedule.Status == db.ScheduleStatusCancelled {
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
|
||||||
|
Cancelled
|
||||||
|
</span>
|
||||||
|
if schedule.RescheduleReason != nil {
|
||||||
|
<p class="text-subtext1 text-xs">{ *schedule.RescheduleReason }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-subtext1 text-sm">No time confirmed</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesMatchList(series *db.PlayoffSeries) {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Matches</h2>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-surface1">
|
||||||
|
for _, match := range series.Matches {
|
||||||
|
@seriesMatchRow(series, match)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesMatchRow(series *db.PlayoffSeries, match *db.PlayoffMatch) {
|
||||||
|
{{
|
||||||
|
matchLabel := fmt.Sprintf("Game %d", match.MatchNumber)
|
||||||
|
isPending := match.Status == "pending"
|
||||||
|
isCompleted := match.Status == "completed"
|
||||||
|
hasFixture := match.FixtureID != nil
|
||||||
|
_ = hasFixture
|
||||||
|
}}
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 hover:bg-surface0 transition-colors">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm font-medium text-text">{ matchLabel }</span>
|
||||||
|
if isPending {
|
||||||
|
<span class="px-1.5 py-0.5 bg-surface1 text-subtext0 rounded text-xs">
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
} else if isCompleted {
|
||||||
|
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs">
|
||||||
|
Complete
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs">
|
||||||
|
{ match.Status }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if match.FixtureID != nil {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/overview", *match.FixtureID)) }
|
||||||
|
class="px-3 py-1 bg-surface1 hover:bg-surface2 text-text rounded text-xs
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesContextCard(series *db.PlayoffSeries) {
|
||||||
|
{{
|
||||||
|
// Determine advancement info
|
||||||
|
winnerAdvances := ""
|
||||||
|
loserAdvances := ""
|
||||||
|
|
||||||
|
if series.WinnerNextID != nil {
|
||||||
|
// Look through bracket series for the target
|
||||||
|
if series.Bracket != nil {
|
||||||
|
for _, s := range series.Bracket.Series {
|
||||||
|
if s.ID == *series.WinnerNextID {
|
||||||
|
winnerAdvances = s.Label
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if winnerAdvances == "" {
|
||||||
|
winnerAdvances = "next round"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if series.LoserNextID != nil {
|
||||||
|
if series.Bracket != nil {
|
||||||
|
for _, s := range series.Bracket.Series {
|
||||||
|
if s.ID == *series.LoserNextID {
|
||||||
|
loserAdvances = s.Label
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loserAdvances == "" {
|
||||||
|
loserAdvances = "next round"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Series Info</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Round</span>
|
||||||
|
<span class="text-sm font-medium text-text">{ roundDisplayName(series.Round) }</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Format</span>
|
||||||
|
<span class="text-sm font-medium text-text">Best of { fmt.Sprint(series.MatchesToWin*2 - 1) } (first to { fmt.Sprint(series.MatchesToWin) })</span>
|
||||||
|
</div>
|
||||||
|
if series.Team1Seed != nil && series.Team2Seed != nil {
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Seeding</span>
|
||||||
|
<span class="text-sm font-medium text-text">
|
||||||
|
{ ordinal(*series.Team1Seed) } seed vs { ordinal(*series.Team2Seed) } seed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if winnerAdvances != "" {
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Winner →</span>
|
||||||
|
<span class="text-sm font-medium text-green">{ winnerAdvances }</span>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Winner →</span>
|
||||||
|
<span class="text-sm font-medium text-yellow">Champion</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if loserAdvances != "" {
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Loser →</span>
|
||||||
|
<span class="text-sm font-medium text-peach">{ loserAdvances }</span>
|
||||||
|
</div>
|
||||||
|
} else if series.WinnerNextID != nil {
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Loser →</span>
|
||||||
|
<span class="text-sm font-medium text-red">Eliminated</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, season *db.Season, league *db.League) {
|
||||||
|
{{
|
||||||
|
// Sort with managers first
|
||||||
|
sort.SliceStable(players, func(i, j int) bool {
|
||||||
|
return players[i].IsManager && !players[j].IsManager
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
<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">
|
||||||
|
@links.TeamNameLinkInSeason(team, season, league)
|
||||||
|
</h3>
|
||||||
|
if team.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-4 h-4 rounded-full border border-surface1"
|
||||||
|
style={ fmt.Sprintf("background-color: %s", team.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if len(players) == 0 {
|
||||||
|
<div class="p-4">
|
||||||
|
<p class="text-subtext1 text-sm text-center py-2">No players on roster.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
for _, p := range players {
|
||||||
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface0 transition">
|
||||||
|
<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">
|
||||||
|
★ Manager
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
if p.IsFreeAgent {
|
||||||
|
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||||
|
FREE AGENT
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
251
internal/view/seasonsview/series_match_analysis.templ
Normal file
251
internal/view/seasonsview/series_match_analysis.templ
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// seriesMatchAnalysisTab renders the full Match Analysis tab for completed series.
|
||||||
|
// Shows final series score, individual match results, aggregated team stats,
|
||||||
|
// top performers, and league context.
|
||||||
|
templ seriesMatchAnalysisTab(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
|
preview *db.MatchPreviewData,
|
||||||
|
) {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Final Series Score -->
|
||||||
|
@seriesAnalysisScoreHeader(series)
|
||||||
|
|
||||||
|
<!-- Individual Match Results -->
|
||||||
|
if len(series.Matches) > 0 {
|
||||||
|
@seriesAnalysisMatchResults(series)
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- League Context (from preview data) -->
|
||||||
|
if preview != nil {
|
||||||
|
@seriesAnalysisLeagueContext(series, preview)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesAnalysisScoreHeader renders the final series score in a prominent display.
|
||||||
|
templ seriesAnalysisScoreHeader(series *db.PlayoffSeries) {
|
||||||
|
{{
|
||||||
|
team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID
|
||||||
|
team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID
|
||||||
|
}}
|
||||||
|
<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 Series Score</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-center gap-6 sm:gap-10">
|
||||||
|
<!-- Team 1 -->
|
||||||
|
<div class="flex flex-col items-center text-center flex-1">
|
||||||
|
if series.Team1 != nil && series.Team1.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
|
||||||
|
if series.Team1 != nil {
|
||||||
|
@links.TeamNameLinkInSeason(series.Team1, series.Bracket.Season, series.Bracket.League)
|
||||||
|
} else {
|
||||||
|
TBD
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", team1Won), templ.KV("text-text", !team1Won) }>
|
||||||
|
{ fmt.Sprint(series.Team1Wins) }
|
||||||
|
</span>
|
||||||
|
if team1Won {
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<!-- Team 2 -->
|
||||||
|
<div class="flex flex-col items-center text-center flex-1">
|
||||||
|
if series.Team2 != nil && series.Team2.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
|
||||||
|
if series.Team2 != nil {
|
||||||
|
@links.TeamNameLinkInSeason(series.Team2, series.Bracket.Season, series.Bracket.League)
|
||||||
|
} else {
|
||||||
|
TBD
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", team2Won), templ.KV("text-text", !team2Won) }>
|
||||||
|
{ fmt.Sprint(series.Team2Wins) }
|
||||||
|
</span>
|
||||||
|
if team2Won {
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesAnalysisMatchResults shows individual match results as a compact list.
|
||||||
|
templ seriesAnalysisMatchResults(series *db.PlayoffSeries) {
|
||||||
|
<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">Match Results</h2>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-surface1">
|
||||||
|
for _, match := range series.Matches {
|
||||||
|
@seriesAnalysisMatchRow(series, match)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesAnalysisMatchRow(series *db.PlayoffSeries, match *db.PlayoffMatch) {
|
||||||
|
{{
|
||||||
|
matchLabel := fmt.Sprintf("Game %d", match.MatchNumber)
|
||||||
|
isCompleted := match.Status == "completed"
|
||||||
|
}}
|
||||||
|
<div class="flex items-center justify-between px-6 py-3 hover:bg-surface0 transition-colors">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-sm font-medium text-subtext0 w-16">{ matchLabel }</span>
|
||||||
|
if isCompleted {
|
||||||
|
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs">
|
||||||
|
Complete
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-1.5 py-0.5 bg-surface1 text-subtext0 rounded text-xs">
|
||||||
|
{ match.Status }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if match.FixtureID != nil {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/overview", *match.FixtureID)) }
|
||||||
|
class="px-3 py-1 bg-surface1 hover:bg-surface2 text-text rounded text-xs
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesAnalysisLeagueContext shows how the teams sit in the league standings.
|
||||||
|
templ seriesAnalysisLeagueContext(series *db.PlayoffSeries, 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 series.Team1 != nil && series.Team1.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<span class="text-sm font-bold text-text">{ seriesTeamShortName(series.Team1) }</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">{ seriesTeamShortName(series.Team2) }</span>
|
||||||
|
if series.Team2 != nil && series.Team2.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team2.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>
|
||||||
|
}
|
||||||
319
internal/view/seasonsview/series_match_preview.templ
Normal file
319
internal/view/seasonsview/series_match_preview.templ
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
|
import "fmt"
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
// seriesMatchPreviewTab renders the full Match Preview tab for upcoming series.
|
||||||
|
// Shows team standings comparison, recent form, and full rosters side-by-side.
|
||||||
|
templ seriesMatchPreviewTab(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
|
preview *db.MatchPreviewData,
|
||||||
|
) {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Team Comparison Header -->
|
||||||
|
if preview != nil {
|
||||||
|
@seriesPreviewHeader(series, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Form Guide (Last 5 Games) -->
|
||||||
|
if preview != nil {
|
||||||
|
@seriesPreviewFormGuide(series, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Team Rosters -->
|
||||||
|
@seriesPreviewRosters(series, rosters)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesPreviewHeader renders the broadcast-style team comparison with standings.
|
||||||
|
templ seriesPreviewHeader(series *db.PlayoffSeries, 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">
|
||||||
|
<!-- Team 1 -->
|
||||||
|
<div class="flex flex-col items-center text-center flex-1">
|
||||||
|
if series.Team1 != nil && series.Team1.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<h3 class="text-xl sm:text-2xl font-bold text-text">
|
||||||
|
if series.Team1 != nil {
|
||||||
|
@links.TeamNameLinkInSeason(series.Team1, series.Bracket.Season, series.Bracket.League)
|
||||||
|
} else {
|
||||||
|
TBD
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
if series.Team1 != nil {
|
||||||
|
<span class="text-subtext0 text-sm font-mono mt-1">{ series.Team1.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>
|
||||||
|
<!-- Team 2 -->
|
||||||
|
<div class="flex flex-col items-center text-center flex-1">
|
||||||
|
if series.Team2 != nil && series.Team2.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<h3 class="text-xl sm:text-2xl font-bold text-text">
|
||||||
|
if series.Team2 != nil {
|
||||||
|
@links.TeamNameLinkInSeason(series.Team2, series.Bracket.Season, series.Bracket.League)
|
||||||
|
} else {
|
||||||
|
TBD
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
if series.Team2 != nil {
|
||||||
|
<span class="text-subtext0 text-sm font-mono mt-1">{ series.Team2.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">
|
||||||
|
@previewStatRow(
|
||||||
|
homePos,
|
||||||
|
"Position",
|
||||||
|
awayPos,
|
||||||
|
preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition,
|
||||||
|
preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.Points),
|
||||||
|
"Points",
|
||||||
|
fmt.Sprint(preview.AwayRecord.Points),
|
||||||
|
preview.HomeRecord.Points > preview.AwayRecord.Points,
|
||||||
|
preview.AwayRecord.Points > preview.HomeRecord.Points,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.Played),
|
||||||
|
"Played",
|
||||||
|
fmt.Sprint(preview.AwayRecord.Played),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.Wins),
|
||||||
|
"Wins",
|
||||||
|
fmt.Sprint(preview.AwayRecord.Wins),
|
||||||
|
preview.HomeRecord.Wins > preview.AwayRecord.Wins,
|
||||||
|
preview.AwayRecord.Wins > preview.HomeRecord.Wins,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.OvertimeWins),
|
||||||
|
"OT Wins",
|
||||||
|
fmt.Sprint(preview.AwayRecord.OvertimeWins),
|
||||||
|
preview.HomeRecord.OvertimeWins > preview.AwayRecord.OvertimeWins,
|
||||||
|
preview.AwayRecord.OvertimeWins > preview.HomeRecord.OvertimeWins,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.OvertimeLosses),
|
||||||
|
"OT Losses",
|
||||||
|
fmt.Sprint(preview.AwayRecord.OvertimeLosses),
|
||||||
|
preview.HomeRecord.OvertimeLosses < preview.AwayRecord.OvertimeLosses,
|
||||||
|
preview.AwayRecord.OvertimeLosses < preview.HomeRecord.OvertimeLosses,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.Losses),
|
||||||
|
"Losses",
|
||||||
|
fmt.Sprint(preview.AwayRecord.Losses),
|
||||||
|
preview.HomeRecord.Losses < preview.AwayRecord.Losses,
|
||||||
|
preview.AwayRecord.Losses < preview.HomeRecord.Losses,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.GoalsFor),
|
||||||
|
"Goals For",
|
||||||
|
fmt.Sprint(preview.AwayRecord.GoalsFor),
|
||||||
|
preview.HomeRecord.GoalsFor > preview.AwayRecord.GoalsFor,
|
||||||
|
preview.AwayRecord.GoalsFor > preview.HomeRecord.GoalsFor,
|
||||||
|
)
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprint(preview.HomeRecord.GoalsAgainst),
|
||||||
|
"Goals Against",
|
||||||
|
fmt.Sprint(preview.AwayRecord.GoalsAgainst),
|
||||||
|
preview.HomeRecord.GoalsAgainst < preview.AwayRecord.GoalsAgainst,
|
||||||
|
preview.AwayRecord.GoalsAgainst < preview.HomeRecord.GoalsAgainst,
|
||||||
|
)
|
||||||
|
{{
|
||||||
|
homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst
|
||||||
|
awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst
|
||||||
|
}}
|
||||||
|
@previewStatRow(
|
||||||
|
fmt.Sprintf("%+d", homeDiff),
|
||||||
|
"Goal Diff",
|
||||||
|
fmt.Sprintf("%+d", awayDiff),
|
||||||
|
homeDiff > awayDiff,
|
||||||
|
awayDiff > homeDiff,
|
||||||
|
)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesPreviewFormGuide renders recent form for each team.
|
||||||
|
templ seriesPreviewFormGuide(series *db.PlayoffSeries, 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">
|
||||||
|
<!-- Team 1 Form -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
if series.Team1 != nil && series.Team1.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<h3 class="text-md font-bold text-text">{ seriesTeamName(series.Team1) }</h3>
|
||||||
|
</div>
|
||||||
|
if len(preview.HomeRecentGames) == 0 {
|
||||||
|
<p class="text-subtext1 text-sm">No recent matches played</p>
|
||||||
|
} else {
|
||||||
|
<div class="flex items-center gap-1.5 mb-4">
|
||||||
|
for _, g := range preview.HomeRecentGames {
|
||||||
|
@gameOutcomeIcon(g)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
for i := len(preview.HomeRecentGames) - 1; i >= 0; i-- {
|
||||||
|
@recentGameRow(preview.HomeRecentGames[i])
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Team 2 Form -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
if series.Team2 != nil && series.Team2.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<h3 class="text-md font-bold text-text">{ seriesTeamName(series.Team2) }</h3>
|
||||||
|
</div>
|
||||||
|
if len(preview.AwayRecentGames) == 0 {
|
||||||
|
<p class="text-subtext1 text-sm">No recent matches played</p>
|
||||||
|
} else {
|
||||||
|
<div class="flex items-center gap-1.5 mb-4">
|
||||||
|
for _, g := range preview.AwayRecentGames {
|
||||||
|
@gameOutcomeIcon(g)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
for i := len(preview.AwayRecentGames) - 1; i >= 0; i-- {
|
||||||
|
@recentGameRow(preview.AwayRecentGames[i])
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesPreviewRosters renders team rosters side-by-side.
|
||||||
|
templ seriesPreviewRosters(series *db.PlayoffSeries, rosters map[string][]*db.PlayerWithPlayStatus) {
|
||||||
|
<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">
|
||||||
|
if series.Team1 != nil {
|
||||||
|
@seriesPreviewRosterColumn(series.Team1, rosters["team1"], series.Bracket.Season, series.Bracket.League)
|
||||||
|
}
|
||||||
|
if series.Team2 != nil {
|
||||||
|
@seriesPreviewRosterColumn(series.Team2, rosters["team2"], series.Bracket.Season, series.Bracket.League)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesPreviewRosterColumn(
|
||||||
|
team *db.Team,
|
||||||
|
players []*db.PlayerWithPlayStatus,
|
||||||
|
season *db.Season,
|
||||||
|
league *db.League,
|
||||||
|
) {
|
||||||
|
{{
|
||||||
|
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.Slice(roster, func(i, j int) bool {
|
||||||
|
return roster[i].Player.DisplayName() < roster[j].Player.DisplayName()
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
for _, p := range managers {
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface0 border border-surface1">
|
||||||
|
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium shrink-0">
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
@links.PlayerLink(p.Player)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
374
internal/view/seasonsview/series_review_result.templ
Normal file
374
internal/view/seasonsview/series_review_result.templ
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
// SeriesGameResult holds the parsed result for a single game in the series review
|
||||||
|
type SeriesGameResult struct {
|
||||||
|
GameNumber int
|
||||||
|
Result *db.FixtureResult
|
||||||
|
UnmappedPlayers []string
|
||||||
|
FreeAgentWarnings []FreeAgentWarning
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SeriesReviewResultPage(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
gameResults []*SeriesGameResult,
|
||||||
|
) {
|
||||||
|
{{
|
||||||
|
backURL := fmt.Sprintf("/series/%d", series.ID)
|
||||||
|
team1Name := seriesTeamName(series.Team1)
|
||||||
|
team2Name := seriesTeamName(series.Team2)
|
||||||
|
|
||||||
|
// Calculate series score from the results
|
||||||
|
team1Wins := 0
|
||||||
|
team2Wins := 0
|
||||||
|
for _, gr := range gameResults {
|
||||||
|
if gr.Result != nil {
|
||||||
|
if gr.Result.Winner == "home" {
|
||||||
|
team1Wins++
|
||||||
|
} else {
|
||||||
|
team2Wins++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@baseview.Layout(fmt.Sprintf("Review Series Result — %s vs %s", team1Name, team2Name)) {
|
||||||
|
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-6">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-text mb-1">Review Series Result</h1>
|
||||||
|
<p class="text-sm text-subtext1">
|
||||||
|
{ team1Name } vs { team2Name }
|
||||||
|
<span class="text-subtext0 ml-1">{ series.Label }</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(backURL) }
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
|
bg-surface1 hover:bg-surface2 text-text transition"
|
||||||
|
>
|
||||||
|
Back to Series
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Series Score Summary -->
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Series Result</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-center gap-8 py-2">
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
|
if series.Team1 != nil && series.Team1.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<p class="text-sm font-medium text-subtext0 mb-1">{ team1Name }</p>
|
||||||
|
<p class={ "text-5xl font-bold", templ.KV("text-green", team1Wins > team2Wins), templ.KV("text-text", team1Wins <= team2Wins) }>
|
||||||
|
{ fmt.Sprint(team1Wins) }
|
||||||
|
</p>
|
||||||
|
if team1Wins > team2Wins {
|
||||||
|
<span class="mt-1 px-2 py-0.5 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="text-3xl text-subtext0 font-light">–</span>
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
|
if series.Team2 != nil && series.Team2.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<p class="text-sm font-medium text-subtext0 mb-1">{ team2Name }</p>
|
||||||
|
<p class={ "text-5xl font-bold", templ.KV("text-green", team2Wins > team1Wins), templ.KV("text-text", team2Wins <= team1Wins) }>
|
||||||
|
{ fmt.Sprint(team2Wins) }
|
||||||
|
</p>
|
||||||
|
if team2Wins > team1Wins {
|
||||||
|
<span class="mt-1 px-2 py-0.5 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-center text-sm text-subtext1 mt-3">
|
||||||
|
{ fmt.Sprint(len(gameResults)) } game(s) played
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Per-Game Results -->
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
for _, gr := range gameResults {
|
||||||
|
@seriesReviewGameCard(series, gr)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Actions</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
hx-post={ fmt.Sprintf("/series/%d/results/finalize", series.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
class="px-6 py-3 bg-green hover:bg-green/75 text-mantle rounded-lg
|
||||||
|
font-semibold transition hover:cursor-pointer text-lg"
|
||||||
|
>
|
||||||
|
Finalize Series
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Discard All Results', message: 'Are you sure you want to discard all uploaded results? You will need to re-upload the match logs for every game.', action: () => htmx.ajax('POST', '/series/%d/results/discard', { swap: 'none' }) } }))", series.ID)) }
|
||||||
|
class="px-6 py-3 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer text-lg"
|
||||||
|
>
|
||||||
|
Discard & Re-upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesReviewGameCard(series *db.PlayoffSeries, gr *SeriesGameResult) {
|
||||||
|
{{
|
||||||
|
team1Name := seriesTeamName(series.Team1)
|
||||||
|
team2Name := seriesTeamName(series.Team2)
|
||||||
|
result := gr.Result
|
||||||
|
homeWon := result.Winner == "home"
|
||||||
|
winnerName := team2Name
|
||||||
|
if homeWon {
|
||||||
|
winnerName = team1Name
|
||||||
|
}
|
||||||
|
hasWarnings := result.TamperingDetected || len(gr.UnmappedPlayers) > 0 || len(gr.FreeAgentWarnings) > 0
|
||||||
|
}}
|
||||||
|
<div
|
||||||
|
class="bg-mantle border border-surface1 rounded-lg overflow-hidden"
|
||||||
|
x-data="{ expanded: true }"
|
||||||
|
>
|
||||||
|
<!-- Game Header (clickable to expand/collapse) -->
|
||||||
|
<div
|
||||||
|
class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between
|
||||||
|
hover:bg-surface1 transition hover:cursor-pointer"
|
||||||
|
@click="expanded = !expanded"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h3 class="text-md font-bold text-text">Game { fmt.Sprint(gr.GameNumber) }</h3>
|
||||||
|
if hasWarnings {
|
||||||
|
<span class="text-yellow text-sm" title="Has warnings">⚠</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0">
|
||||||
|
{ team1Name }
|
||||||
|
<span class="font-bold text-text mx-1">{ fmt.Sprint(result.HomeScore) }</span>
|
||||||
|
-
|
||||||
|
<span class="font-bold text-text mx-1">{ fmt.Sprint(result.AwayScore) }</span>
|
||||||
|
{ team2Name }
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
||||||
|
{ winnerName }
|
||||||
|
</span>
|
||||||
|
<!-- Expand/collapse indicator -->
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-subtext0 transition-transform"
|
||||||
|
:class="expanded && 'rotate-180'"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Collapsible Content -->
|
||||||
|
<div x-show="expanded" x-collapse>
|
||||||
|
<!-- Warnings -->
|
||||||
|
if hasWarnings {
|
||||||
|
<div class="p-4 space-y-3 border-b border-surface1">
|
||||||
|
if result.TamperingDetected && result.TamperingReason != nil {
|
||||||
|
<div class="bg-red/10 border border-red/30 rounded-lg p-3">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="text-red font-bold text-sm">⚠ Inconsistent Data Detected</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-red/80 text-sm">{ *result.TamperingReason }</p>
|
||||||
|
<p class="text-red/60 text-xs mt-1">
|
||||||
|
This does not block finalization but should be reviewed carefully.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if len(gr.FreeAgentWarnings) > 0 {
|
||||||
|
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-3">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="text-yellow font-bold text-sm">⚠ Free Agent Issues</span>
|
||||||
|
</div>
|
||||||
|
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
|
||||||
|
for _, fa := range gr.FreeAgentWarnings {
|
||||||
|
<li>
|
||||||
|
<span class="text-yellow font-medium">{ fa.Name }</span>
|
||||||
|
<span class="text-yellow/60"> — { fa.Reason }</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if len(gr.UnmappedPlayers) > 0 {
|
||||||
|
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-3">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="text-yellow font-bold text-sm">⚠ Unmapped Players</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-yellow/80 text-sm mb-1">
|
||||||
|
Could not be matched to registered players.
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
|
||||||
|
for _, p := range gr.UnmappedPlayers {
|
||||||
|
<li>{ p }</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Score Display -->
|
||||||
|
<div class="p-6 border-b border-surface1">
|
||||||
|
<div class="flex items-center justify-center gap-8 py-2">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-subtext0 mb-1">{ team1Name }</p>
|
||||||
|
<p class={ "text-4xl font-bold", templ.KV("text-green", homeWon), templ.KV("text-text", !homeWon) }>
|
||||||
|
{ 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">{ team2Name }</p>
|
||||||
|
<p class={ "text-4xl font-bold", templ.KV("text-green", !homeWon), templ.KV("text-text", homeWon) }>
|
||||||
|
{ fmt.Sprint(result.AwayScore) }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-4 mt-2 text-xs text-subtext1">
|
||||||
|
if result.Arena != "" {
|
||||||
|
<span>{ result.Arena }</span>
|
||||||
|
}
|
||||||
|
if result.EndReason != "" {
|
||||||
|
<span>{ result.EndReason }</span>
|
||||||
|
}
|
||||||
|
<span>
|
||||||
|
Winner: { winnerName }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Player Stats Tables -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-0 lg:divide-x divide-surface1">
|
||||||
|
if series.Team1 != nil {
|
||||||
|
@seriesReviewTeamStats(series.Team1, result, "home", series.Bracket.Season, series.Bracket.League)
|
||||||
|
}
|
||||||
|
if series.Team2 != nil {
|
||||||
|
@seriesReviewTeamStats(series.Team2, result, "away", series.Bracket.Season, series.Bracket.League)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesReviewTeamStats(team *db.Team, result *db.FixtureResult, side string, season *db.Season, league *db.League) {
|
||||||
|
{{
|
||||||
|
type playerStat struct {
|
||||||
|
Username string
|
||||||
|
PlayerID *int
|
||||||
|
Stats *db.FixtureResultPlayerStats
|
||||||
|
}
|
||||||
|
finalStats := []*playerStat{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, ps := range result.PlayerStats {
|
||||||
|
if ps.Team == side && ps.PeriodNum == 3 {
|
||||||
|
if !seen[ps.PlayerGameUserID] {
|
||||||
|
seen[ps.PlayerGameUserID] = true
|
||||||
|
finalStats = append(finalStats, &playerStat{
|
||||||
|
Username: ps.PlayerUsername,
|
||||||
|
PlayerID: ps.PlayerID,
|
||||||
|
Stats: ps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div>
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-2 flex items-center gap-2">
|
||||||
|
if team.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<h4 class="text-sm font-bold text-text">
|
||||||
|
if side == "home" {
|
||||||
|
Team 1 —
|
||||||
|
} else {
|
||||||
|
Team 2 —
|
||||||
|
}
|
||||||
|
@links.TeamNameLinkInSeason(team, season, league)
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<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">
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
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 {
|
||||||
|
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||||
|
FA
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Shots) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Blocks) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Passes) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Score) }</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
if len(finalStats) == 0 {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="px-3 py-4 text-center text-sm text-subtext1">
|
||||||
|
No player stats recorded
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
504
internal/view/seasonsview/series_schedule.templ
Normal file
504
internal/view/seasonsview/series_schedule.templ
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// ==================== Schedule Tab ====================
|
||||||
|
templ seriesScheduleTab(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
currentSchedule *db.PlayoffSeriesSchedule,
|
||||||
|
history []*db.PlayoffSeriesSchedule,
|
||||||
|
canSchedule bool,
|
||||||
|
canManage bool,
|
||||||
|
userTeamID int,
|
||||||
|
) {
|
||||||
|
<div class="space-y-6">
|
||||||
|
@seriesScheduleStatus(series, currentSchedule, canSchedule, canManage, userTeamID)
|
||||||
|
@seriesScheduleActions(series, currentSchedule, canSchedule, canManage, userTeamID)
|
||||||
|
@seriesScheduleHistory(series, history)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesScheduleStatus(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
current *db.PlayoffSeriesSchedule,
|
||||||
|
canSchedule bool,
|
||||||
|
canManage bool,
|
||||||
|
userTeamID int,
|
||||||
|
) {
|
||||||
|
{{
|
||||||
|
bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Schedule Status</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
if !bothTeamsAssigned {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">⏳</div>
|
||||||
|
<p class="text-lg text-text font-medium">Waiting for Teams</p>
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
Both teams must be determined before scheduling can begin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if current == nil {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">📅</div>
|
||||||
|
<p class="text-lg text-text font-medium">No time scheduled</p>
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
if canSchedule {
|
||||||
|
Use the form to propose a time for this series.
|
||||||
|
} else {
|
||||||
|
A team manager needs to propose a time for this series.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusPending && current.ScheduledTime != nil {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">⏳</div>
|
||||||
|
<p class="text-lg text-text font-medium">
|
||||||
|
Proposed:
|
||||||
|
@localtime(current.ScheduledTime, "datetime")
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
Proposed by
|
||||||
|
<span class="text-text font-medium">{ current.ProposedBy.Name }</span>
|
||||||
|
— awaiting response from the other team
|
||||||
|
</p>
|
||||||
|
if canSchedule && userTeamID != current.ProposedByTeamID {
|
||||||
|
<div class="flex justify-center gap-3 mt-4">
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/series/%d/schedule/%d/accept", series.ID, current.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/series/%d/schedule/%d/reject", series.ID, current.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if canSchedule && userTeamID == current.ProposedByTeamID {
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={ templ.JSUnsafeFuncCall("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Withdraw Proposal', message: 'Are you sure you want to withdraw your proposed time?', action: () => htmx.ajax('POST', '/series/" + fmt.Sprint(series.ID) + "/schedule/" + fmt.Sprint(current.ID) + "/withdraw', { swap: 'none' }) } }))") }
|
||||||
|
class="px-4 py-2 bg-surface1 hover:bg-surface2 text-text rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Withdraw Proposal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusAccepted {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">✅</div>
|
||||||
|
<p class="text-lg text-green font-medium">
|
||||||
|
Confirmed:
|
||||||
|
@localtime(current.ScheduledTime, "datetime")
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
Both teams have agreed on this time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusRejected {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">❌</div>
|
||||||
|
<p class="text-lg text-red font-medium">Proposal Rejected</p>
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
The proposed time was rejected. A new time needs to be proposed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusCancelled {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">🚫</div>
|
||||||
|
<p class="text-lg text-red font-medium">Schedule Cancelled</p>
|
||||||
|
if current.RescheduleReason != nil {
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
{ *current.RescheduleReason }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusRescheduled {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">🔄</div>
|
||||||
|
<p class="text-lg text-yellow font-medium">Rescheduled</p>
|
||||||
|
if current.RescheduleReason != nil {
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
Reason: { *current.RescheduleReason }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
A new time needs to be proposed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusPostponed {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">⏸️</div>
|
||||||
|
<p class="text-lg text-peach font-medium">Postponed</p>
|
||||||
|
if current.RescheduleReason != nil {
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
Reason: { *current.RescheduleReason }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
A new time needs to be proposed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusWithdrawn {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">↩️</div>
|
||||||
|
<p class="text-lg text-subtext0 font-medium">Proposal Withdrawn</p>
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
The proposed time was withdrawn. A new time needs to be proposed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesScheduleActions(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
current *db.PlayoffSeriesSchedule,
|
||||||
|
canSchedule bool,
|
||||||
|
canManage bool,
|
||||||
|
userTeamID int,
|
||||||
|
) {
|
||||||
|
{{
|
||||||
|
bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil
|
||||||
|
|
||||||
|
showPropose := false
|
||||||
|
showReschedule := false
|
||||||
|
showPostpone := false
|
||||||
|
showCancel := false
|
||||||
|
|
||||||
|
if bothTeamsAssigned && canSchedule {
|
||||||
|
if current == nil {
|
||||||
|
showPropose = true
|
||||||
|
} else if current.Status == db.ScheduleStatusRejected {
|
||||||
|
showPropose = true
|
||||||
|
} else if current.Status == db.ScheduleStatusRescheduled {
|
||||||
|
showPropose = true
|
||||||
|
} else if current.Status == db.ScheduleStatusPostponed {
|
||||||
|
showPropose = true
|
||||||
|
} else if current.Status == db.ScheduleStatusWithdrawn {
|
||||||
|
showPropose = true
|
||||||
|
} else if current.Status == db.ScheduleStatusAccepted {
|
||||||
|
showReschedule = true
|
||||||
|
showPostpone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bothTeamsAssigned && canManage && current != nil && !current.Status.IsTerminal() {
|
||||||
|
showCancel = true
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
if showPropose || showReschedule || showPostpone || showCancel {
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<!-- Propose Time -->
|
||||||
|
if showPropose {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
||||||
|
<h3 class="text-md font-bold text-text">Propose Time</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/series/%d/schedule", series.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-subtext0 mb-1">
|
||||||
|
Date & Time
|
||||||
|
<span class="relative group inline-block ml-1">
|
||||||
|
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium cursor-help">AEST/AEDT</span>
|
||||||
|
<span
|
||||||
|
class="absolute left-0 top-full mt-1 w-56 bg-crust border border-surface1 rounded-lg
|
||||||
|
p-2 text-xs text-subtext0 shadow-lg opacity-0 invisible
|
||||||
|
group-hover:opacity-100 group-hover:visible transition-all z-50"
|
||||||
|
>
|
||||||
|
Enter the time in Australian Eastern time (AEST/AEDT). It will be displayed in each user's local timezone.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="scheduled_time"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
focus:border-blue focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Propose Time
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Reschedule -->
|
||||||
|
if showReschedule {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
||||||
|
<h3 class="text-md font-bold text-text">Reschedule</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/series/%d/schedule/reschedule", series.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-subtext0 mb-1">
|
||||||
|
New Date & Time
|
||||||
|
<span class="relative group inline-block ml-1">
|
||||||
|
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium cursor-help">AEST/AEDT</span>
|
||||||
|
<span
|
||||||
|
class="absolute left-0 top-full mt-1 w-56 bg-crust border border-surface1 rounded-lg
|
||||||
|
p-2 text-xs text-subtext0 shadow-lg opacity-0 invisible
|
||||||
|
group-hover:opacity-100 group-hover:visible transition-all z-50"
|
||||||
|
>
|
||||||
|
Enter the time in Australian Eastern time (AEST/AEDT). It will be displayed in each user's local timezone.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="scheduled_time"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
focus:border-blue focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
|
||||||
|
@seriesRescheduleReasonSelect(series)
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full px-4 py-2 bg-yellow hover:bg-yellow/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Reschedule
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Postpone -->
|
||||||
|
if showPostpone {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
||||||
|
<h3 class="text-md font-bold text-text">Postpone</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
|
||||||
|
@seriesRescheduleReasonSelect(series)
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("if (!this.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Postpone Series', message: 'Are you sure you want to postpone this series? The current schedule will be cleared.', action: () => htmx.ajax('POST', '/series/%d/schedule/postpone', { swap: 'none', values: { reschedule_reason: this.parentElement.querySelector('select').value } }) } }))", series.ID)) }
|
||||||
|
class="w-full px-4 py-2 bg-peach hover:bg-peach/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Postpone Series
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Cancel (moderator only) -->
|
||||||
|
if showCancel {
|
||||||
|
<div class="bg-mantle border border-red/30 rounded-lg">
|
||||||
|
<div class="bg-red/10 border-b border-red/30 px-4 py-3 rounded-t-lg">
|
||||||
|
<h3 class="text-md font-bold text-red">Cancel Schedule</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<p class="text-xs text-red/80 mb-3 font-medium">
|
||||||
|
This action will cancel the current series schedule.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="reschedule_reason"
|
||||||
|
placeholder="Enter reason..."
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
focus:border-blue focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("var reason = this.parentElement.querySelector('input[name=reschedule_reason]').value; if (!reason) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Cancel Schedule', message: 'Are you sure you want to cancel this schedule?', action: () => htmx.ajax('POST', '/series/%d/schedule/cancel', { swap: 'none', values: { reschedule_reason: reason } }) } }))", series.ID)) }
|
||||||
|
class="w-full px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel Schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
if !canSchedule && !canManage {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||||||
|
<p class="text-subtext1 text-sm">
|
||||||
|
Only team managers can manage series scheduling.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesRescheduleReasonSelect(series *db.PlayoffSeries) {
|
||||||
|
{{
|
||||||
|
team1Name := seriesTeamName(series.Team1)
|
||||||
|
team2Name := seriesTeamName(series.Team2)
|
||||||
|
}}
|
||||||
|
<select
|
||||||
|
name="reschedule_reason"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="" disabled selected>Select a reason</option>
|
||||||
|
<option value="Mutually Agreed">Mutually Agreed</option>
|
||||||
|
<option value={ fmt.Sprintf("%s Unavailable", team1Name) }>
|
||||||
|
{ team1Name } Unavailable
|
||||||
|
</option>
|
||||||
|
<option value={ fmt.Sprintf("%s Unavailable", team2Name) }>
|
||||||
|
{ team2Name } Unavailable
|
||||||
|
</option>
|
||||||
|
<option value={ fmt.Sprintf("%s No-show", team1Name) }>
|
||||||
|
{ team1Name } No-show
|
||||||
|
</option>
|
||||||
|
<option value={ fmt.Sprintf("%s No-show", team2Name) }>
|
||||||
|
{ team2Name } No-show
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesScheduleHistory(series *db.PlayoffSeries, history []*db.PlayoffSeriesSchedule) {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Schedule History</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
if len(history) == 0 {
|
||||||
|
<p class="text-subtext1 text-sm text-center py-4">No scheduling activity yet.</p>
|
||||||
|
} else {
|
||||||
|
<div class="space-y-3">
|
||||||
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
|
@seriesScheduleHistoryItem(history[i], i == len(history)-1)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesScheduleHistoryItem(schedule *db.PlayoffSeriesSchedule, isCurrent bool) {
|
||||||
|
{{
|
||||||
|
statusColor := "text-subtext0"
|
||||||
|
statusBg := "bg-surface1"
|
||||||
|
statusLabel := string(schedule.Status)
|
||||||
|
switch schedule.Status {
|
||||||
|
case db.ScheduleStatusPending:
|
||||||
|
statusColor = "text-blue"
|
||||||
|
statusBg = "bg-blue/20"
|
||||||
|
statusLabel = "Pending"
|
||||||
|
case db.ScheduleStatusAccepted:
|
||||||
|
statusColor = "text-green"
|
||||||
|
statusBg = "bg-green/20"
|
||||||
|
statusLabel = "Accepted"
|
||||||
|
case db.ScheduleStatusRejected:
|
||||||
|
statusColor = "text-red"
|
||||||
|
statusBg = "bg-red/20"
|
||||||
|
statusLabel = "Rejected"
|
||||||
|
case db.ScheduleStatusRescheduled:
|
||||||
|
statusColor = "text-yellow"
|
||||||
|
statusBg = "bg-yellow/20"
|
||||||
|
statusLabel = "Rescheduled"
|
||||||
|
case db.ScheduleStatusPostponed:
|
||||||
|
statusColor = "text-peach"
|
||||||
|
statusBg = "bg-peach/20"
|
||||||
|
statusLabel = "Postponed"
|
||||||
|
case db.ScheduleStatusCancelled:
|
||||||
|
statusColor = "text-red"
|
||||||
|
statusBg = "bg-red/20"
|
||||||
|
statusLabel = "Cancelled"
|
||||||
|
case db.ScheduleStatusWithdrawn:
|
||||||
|
statusColor = "text-subtext0"
|
||||||
|
statusBg = "bg-surface1"
|
||||||
|
statusLabel = "Withdrawn"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class={ "border rounded-lg p-3", templ.KV("border-surface1", !isCurrent), templ.KV("border-blue/30 bg-blue/5", isCurrent) }>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
if isCurrent {
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-blue/20 text-blue rounded font-medium">
|
||||||
|
CURRENT
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class={ "text-xs px-2 py-0.5 rounded font-medium", statusBg, statusColor }>
|
||||||
|
{ statusLabel }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-subtext1">
|
||||||
|
@localtimeUnix(schedule.CreatedAt, "histdate")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-subtext0">Proposed by:</span>
|
||||||
|
<span class="text-text font-medium">{ schedule.ProposedBy.Name }</span>
|
||||||
|
</div>
|
||||||
|
if schedule.ScheduledTime != nil {
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-subtext0">Time:</span>
|
||||||
|
<span class="text-text">
|
||||||
|
@localtime(schedule.ScheduledTime, "datetime")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-subtext0">Time:</span>
|
||||||
|
<span class="text-subtext1 italic">No time set</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if schedule.RescheduleReason != nil {
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-subtext0">Reason:</span>
|
||||||
|
<span class="text-subtext1">{ *schedule.RescheduleReason }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
133
internal/view/seasonsview/series_upload_result.templ
Normal file
133
internal/view/seasonsview/series_upload_result.templ
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ SeriesUploadResultPage(series *db.PlayoffSeries) {
|
||||||
|
{{
|
||||||
|
backURL := fmt.Sprintf("/series/%d", series.ID)
|
||||||
|
team1Name := seriesTeamName(series.Team1)
|
||||||
|
team2Name := seriesTeamName(series.Team2)
|
||||||
|
boLabel := fmt.Sprintf("BO%d", series.MatchesToWin*2-1)
|
||||||
|
maxGames := series.MatchesToWin*2 - 1
|
||||||
|
minGames := series.MatchesToWin
|
||||||
|
}}
|
||||||
|
@baseview.Layout(fmt.Sprintf("Upload Series Result — %s vs %s", team1Name, team2Name)) {
|
||||||
|
<div class="max-w-screen-md mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-6">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-text mb-1">Upload Series Results</h1>
|
||||||
|
<p class="text-sm text-subtext1">
|
||||||
|
{ team1Name } vs { team2Name }
|
||||||
|
<span class="text-subtext0 ml-1">
|
||||||
|
{ series.Label } · { boLabel }
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(backURL) }
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
|
bg-surface1 hover:bg-surface2 text-text transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Upload Form -->
|
||||||
|
<div
|
||||||
|
class="bg-mantle border border-surface1 rounded-lg overflow-hidden"
|
||||||
|
x-data={ fmt.Sprintf("{ gameCount: %d }", minGames) }
|
||||||
|
>
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Match Log Files</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<p class="text-sm text-subtext1 mb-6">
|
||||||
|
Upload the 3 period match log JSON files for each game in the series.
|
||||||
|
Select the number of games that were actually played.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/series/%d/results/upload", series.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
hx-encoding="multipart/form-data"
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<!-- Game Count Selector -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-text mb-2">
|
||||||
|
Number of Games Played
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="game_count"
|
||||||
|
x-model="gameCount"
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
for g := minGames; g <= maxGames; g++ {
|
||||||
|
<option
|
||||||
|
value={ fmt.Sprint(g) }
|
||||||
|
if g == minGames {
|
||||||
|
selected
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ fmt.Sprint(g) } games
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-subtext0 mt-1">
|
||||||
|
First team to { fmt.Sprint(series.MatchesToWin) } wins takes the series
|
||||||
|
({ fmt.Sprint(minGames) }-{ fmt.Sprint(maxGames) } games possible)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Per-Game File Inputs -->
|
||||||
|
for g := 1; g <= maxGames; g++ {
|
||||||
|
<div
|
||||||
|
x-show={ fmt.Sprintf("gameCount >= %d", g) }
|
||||||
|
x-cloak
|
||||||
|
class="border border-surface1 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-2">
|
||||||
|
<h3 class="text-md font-semibold text-text">Game { fmt.Sprint(g) }</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
for p := 1; p <= 3; p++ {
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-subtext0 mb-1">
|
||||||
|
Period { fmt.Sprint(p) }
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name={ fmt.Sprintf("game_%d_period_%d", g, p) }
|
||||||
|
accept=".json"
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
file:mr-4 file:py-1 file:px-3 file:rounded file:border-0
|
||||||
|
file:text-sm file:font-medium file:bg-blue file:text-mantle
|
||||||
|
file:hover:bg-blue/80 file:hover:cursor-pointer file:transition
|
||||||
|
focus:border-blue focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Submit -->
|
||||||
|
<div class="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full px-4 py-3 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer text-lg"
|
||||||
|
>
|
||||||
|
Upload & Validate All Games
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package seasonsview
|
package seasonsview
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
import "time"
|
|
||||||
|
|
||||||
// StatusBadge renders a season status badge
|
// StatusBadge renders a season status badge
|
||||||
// Parameters:
|
// Parameters:
|
||||||
@@ -10,53 +9,34 @@ import "time"
|
|||||||
// - useShortLabels: bool - true for "Active/Finals", false for "In Progress/Finals in Progress"
|
// - useShortLabels: bool - true for "Active/Finals", false for "In Progress/Finals in Progress"
|
||||||
templ StatusBadge(season *db.Season, compact bool, useShortLabels bool) {
|
templ StatusBadge(season *db.Season, compact bool, useShortLabels bool) {
|
||||||
{{
|
{{
|
||||||
now := time.Now()
|
seasonStatus := season.GetStatus()
|
||||||
status := ""
|
status := ""
|
||||||
statusBg := ""
|
statusBg := ""
|
||||||
|
|
||||||
// Determine status based on dates
|
switch seasonStatus {
|
||||||
if now.Before(season.StartDate) {
|
case db.StatusUpcoming:
|
||||||
status = "Upcoming"
|
status = "Upcoming"
|
||||||
statusBg = "bg-blue"
|
statusBg = "bg-blue"
|
||||||
} else if !season.FinalsStartDate.IsZero() {
|
case db.StatusInProgress:
|
||||||
// Finals are scheduled
|
|
||||||
if !season.FinalsEndDate.IsZero() && now.After(season.FinalsEndDate.Time) {
|
|
||||||
// Finals have ended
|
|
||||||
status = "Completed"
|
|
||||||
statusBg = "bg-teal"
|
|
||||||
} else if now.After(season.FinalsStartDate.Time) {
|
|
||||||
// Finals are in progress
|
|
||||||
if useShortLabels {
|
|
||||||
status = "Finals"
|
|
||||||
} else {
|
|
||||||
status = "Finals in Progress"
|
|
||||||
}
|
|
||||||
statusBg = "bg-yellow"
|
|
||||||
} else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) {
|
|
||||||
// Regular season ended, finals upcoming
|
|
||||||
status = "Finals Soon"
|
|
||||||
statusBg = "bg-peach"
|
|
||||||
} else {
|
|
||||||
// Regular season active, finals scheduled for later
|
|
||||||
if useShortLabels {
|
|
||||||
status = "Active"
|
|
||||||
} else {
|
|
||||||
status = "In Progress"
|
|
||||||
}
|
|
||||||
statusBg = "bg-green"
|
|
||||||
}
|
|
||||||
} else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) {
|
|
||||||
// No finals scheduled and regular season ended
|
|
||||||
status = "Completed"
|
|
||||||
statusBg = "bg-teal"
|
|
||||||
} else {
|
|
||||||
// Regular season active, no finals scheduled
|
|
||||||
if useShortLabels {
|
if useShortLabels {
|
||||||
status = "Active"
|
status = "Active"
|
||||||
} else {
|
} else {
|
||||||
status = "In Progress"
|
status = "In Progress"
|
||||||
}
|
}
|
||||||
statusBg = "bg-green"
|
statusBg = "bg-green"
|
||||||
|
case db.StatusFinalsSoon:
|
||||||
|
status = "Finals Soon"
|
||||||
|
statusBg = "bg-peach"
|
||||||
|
case db.StatusFinals:
|
||||||
|
if useShortLabels {
|
||||||
|
status = "Finals"
|
||||||
|
} else {
|
||||||
|
status = "Finals in Progress"
|
||||||
|
}
|
||||||
|
statusBg = "bg-yellow"
|
||||||
|
case db.StatusCompleted:
|
||||||
|
status = "Completed"
|
||||||
|
statusBg = "bg-teal"
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
<span class={ "inline-block px-3 py-1 rounded-full text-sm font-semibold text-mantle " + statusBg }>
|
<span class={ "inline-block px-3 py-1 rounded-full text-sm font-semibold text-mantle " + statusBg }>
|
||||||
|
|||||||
81
internal/view/teamsview/detail_page.templ
Normal file
81
internal/view/teamsview/detail_page.templ
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package teamsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ DetailPage(team *db.Team, seasonInfos []*db.TeamSeasonInfo, playerStats []*db.TeamAllTimePlayerStats, activeTab string) {
|
||||||
|
@baseview.Layout(team.Name) {
|
||||||
|
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
if team.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full border-2 border-surface1 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-bold text-text">{ team.Name }</h1>
|
||||||
|
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||||||
|
{ team.ShortName }
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||||||
|
{ team.AltShortName }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/teams"
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
|
bg-surface1 hover:bg-surface2 text-text transition"
|
||||||
|
>
|
||||||
|
Back to Teams
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<nav class="bg-surface0 border-b border-surface1">
|
||||||
|
<ul class="flex flex-wrap">
|
||||||
|
@teamDetailTab("seasons", "Seasons", activeTab, team)
|
||||||
|
@teamDetailTab("stats", "Player Stats", activeTab, team)
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="mt-6">
|
||||||
|
if activeTab == "seasons" {
|
||||||
|
@TeamDetailSeasons(team, seasonInfos)
|
||||||
|
} else if activeTab == "stats" {
|
||||||
|
@TeamDetailPlayerStats(playerStats)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ teamDetailTab(section string, label string, activeTab string, team *db.Team) {
|
||||||
|
{{
|
||||||
|
isActive := section == activeTab
|
||||||
|
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
|
||||||
|
activeClasses := "border-blue text-blue font-semibold"
|
||||||
|
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
||||||
|
url := fmt.Sprintf("/teams/%d", team.ID)
|
||||||
|
if section != "seasons" {
|
||||||
|
url = fmt.Sprintf("/teams/%d?tab=%s", team.ID, section)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<li class="inline-block">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(url) }
|
||||||
|
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||||||
|
>
|
||||||
|
{ label }
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
130
internal/view/teamsview/detail_player_stats.templ
Normal file
130
internal/view/teamsview/detail_player_stats.templ
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package teamsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
|
import "fmt"
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
templ TeamDetailPlayerStats(playerStats []*db.TeamAllTimePlayerStats) {
|
||||||
|
if len(playerStats) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No player stats yet.</p>
|
||||||
|
<p class="text-subtext1 text-sm mt-2">Player statistics will appear here once games are played.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div x-data="{ activeView: 'goals' }">
|
||||||
|
<!-- Sub-view Tabs -->
|
||||||
|
<div class="flex gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
@click="activeView = 'goals'"
|
||||||
|
:class="activeView === 'goals'
|
||||||
|
? 'bg-blue text-mantle'
|
||||||
|
: 'bg-surface0 border border-surface1 text-subtext0 hover:text-text hover:bg-surface1'"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium text-sm transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Goals
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="activeView = 'assists'"
|
||||||
|
:class="activeView === 'assists'
|
||||||
|
? 'bg-blue text-mantle'
|
||||||
|
: 'bg-surface0 border border-surface1 text-subtext0 hover:text-text hover:bg-surface1'"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium text-sm transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Assists
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="activeView = 'saves'"
|
||||||
|
:class="activeView === 'saves'
|
||||||
|
? 'bg-blue text-mantle'
|
||||||
|
: 'bg-surface0 border border-surface1 text-subtext0 hover:text-text hover:bg-surface1'"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium text-sm transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Saves
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Goals View -->
|
||||||
|
<div x-show="activeView === 'goals'">
|
||||||
|
@playerStatsTable(playerStats, "goals")
|
||||||
|
</div>
|
||||||
|
<!-- Assists View -->
|
||||||
|
<div x-show="activeView === 'assists'" style="display: none;">
|
||||||
|
@playerStatsTable(playerStats, "assists")
|
||||||
|
</div>
|
||||||
|
<!-- Saves View -->
|
||||||
|
<div x-show="activeView === 'saves'" style="display: none;">
|
||||||
|
@playerStatsTable(playerStats, "saves")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ playerStatsTable(playerStats []*db.TeamAllTimePlayerStats, statType string) {
|
||||||
|
{{
|
||||||
|
// Make a copy so sorting doesn't affect other views
|
||||||
|
sorted := make([]*db.TeamAllTimePlayerStats, len(playerStats))
|
||||||
|
copy(sorted, playerStats)
|
||||||
|
|
||||||
|
switch statType {
|
||||||
|
case "goals":
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
return sorted[i].Goals > sorted[j].Goals
|
||||||
|
})
|
||||||
|
case "assists":
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
return sorted[i].Assists > sorted[j].Assists
|
||||||
|
})
|
||||||
|
case "saves":
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
return sorted[i].Saves > sorted[j].Saves
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
statLabel := "Goals"
|
||||||
|
statShort := "G"
|
||||||
|
if statType == "assists" {
|
||||||
|
statLabel = "Assists"
|
||||||
|
statShort = "A"
|
||||||
|
} else if statType == "saves" {
|
||||||
|
statLabel = "Saves"
|
||||||
|
statShort = "SV"
|
||||||
|
}
|
||||||
|
_ = statLabel
|
||||||
|
}}
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-mantle border-b border-surface1">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Seasons Played">SZN</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-blue" title={ statLabel }>{ statShort }</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface1">
|
||||||
|
for i, ps := range sorted {
|
||||||
|
<tr class="hover:bg-surface1 transition-colors">
|
||||||
|
<td class="px-3 py-2 text-center text-sm font-medium text-subtext0">
|
||||||
|
{ fmt.Sprint(i + 1) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-medium">
|
||||||
|
@links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName)
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.SeasonsPlayed) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.PeriodsPlayed) }</td>
|
||||||
|
if statType == "goals" {
|
||||||
|
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(ps.Goals) }</td>
|
||||||
|
} else if statType == "assists" {
|
||||||
|
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(ps.Assists) }</td>
|
||||||
|
} else {
|
||||||
|
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(ps.Saves) }</td>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
103
internal/view/teamsview/detail_seasons.templ
Normal file
103
internal/view/teamsview/detail_seasons.templ
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package teamsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ TeamDetailSeasons(team *db.Team, seasonInfos []*db.TeamSeasonInfo) {
|
||||||
|
if len(seasonInfos) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No season history yet.</p>
|
||||||
|
<p class="text-subtext1 text-sm mt-2">This team has not participated in any seasons.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
for _, info := range seasonInfos {
|
||||||
|
@teamSeasonCard(team, info)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ teamSeasonCard(team *db.Team, info *db.TeamSeasonInfo) {
|
||||||
|
{{
|
||||||
|
detailURL := fmt.Sprintf(
|
||||||
|
"/seasons/%s/leagues/%s/teams/%d",
|
||||||
|
info.Season.ShortName, info.League.ShortName, team.ID,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(detailURL) }
|
||||||
|
class="bg-mantle border border-surface1 rounded-lg overflow-hidden
|
||||||
|
hover:bg-surface0 transition hover:cursor-pointer block"
|
||||||
|
>
|
||||||
|
<!-- Card Header -->
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="text-lg font-bold text-text">{ info.Season.Name }</h3>
|
||||||
|
<span class="text-subtext0 text-sm">—</span>
|
||||||
|
<span class="text-subtext0 text-sm">{ info.League.Name }</span>
|
||||||
|
</div>
|
||||||
|
@seasonsview.StatusBadge(info.Season, true, true)
|
||||||
|
</div>
|
||||||
|
<!-- Card Body -->
|
||||||
|
<div class="p-4">
|
||||||
|
<!-- Position & Points Row -->
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Position Badge -->
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-xs text-subtext0 uppercase font-medium">Position</span>
|
||||||
|
<span class="text-2xl font-bold text-text">
|
||||||
|
{ ordinal(info.Position) }
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-subtext0">
|
||||||
|
/ { fmt.Sprint(info.TotalTeams) }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Points -->
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-xs text-subtext0 uppercase font-medium">Points</span>
|
||||||
|
<p class="text-2xl font-bold text-blue">{ fmt.Sprint(info.Record.Points) }</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Record Row -->
|
||||||
|
<div class="grid grid-cols-4 gap-2 text-center">
|
||||||
|
<div class="bg-surface0 rounded px-2 py-1.5">
|
||||||
|
<p class="text-xs text-subtext0 font-medium">W</p>
|
||||||
|
<p class="text-sm font-bold text-green">{ fmt.Sprint(info.Record.Wins) }</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface0 rounded px-2 py-1.5">
|
||||||
|
<p class="text-xs text-subtext0 font-medium">OTW</p>
|
||||||
|
<p class="text-sm font-bold text-teal">{ fmt.Sprint(info.Record.OvertimeWins) }</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface0 rounded px-2 py-1.5">
|
||||||
|
<p class="text-xs text-subtext0 font-medium">OTL</p>
|
||||||
|
<p class="text-sm font-bold text-peach">{ fmt.Sprint(info.Record.OvertimeLosses) }</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface0 rounded px-2 py-1.5">
|
||||||
|
<p class="text-xs text-subtext0 font-medium">L</p>
|
||||||
|
<p class="text-sm font-bold text-red">{ fmt.Sprint(info.Record.Losses) }</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
func ordinal(n int) string {
|
||||||
|
suffix := "th"
|
||||||
|
if n%100 >= 11 && n%100 <= 13 {
|
||||||
|
// 11th, 12th, 13th
|
||||||
|
} else {
|
||||||
|
switch n % 10 {
|
||||||
|
case 1:
|
||||||
|
suffix = "st"
|
||||||
|
case 2:
|
||||||
|
suffix = "nd"
|
||||||
|
case 3:
|
||||||
|
suffix = "rd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d%s", n, suffix)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import "git.haelnorr.com/h/oslstats/internal/view/sort"
|
|||||||
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
import "github.com/uptrace/bun"
|
import "github.com/uptrace/bun"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
templ ListPage(teams *db.List[db.Team]) {
|
templ ListPage(teams *db.List[db.Team]) {
|
||||||
@baseview.Layout("Teams") {
|
@baseview.Layout("Teams") {
|
||||||
@@ -80,8 +81,10 @@ templ TeamsList(teams *db.List[db.Team]) {
|
|||||||
<!-- Card grid -->
|
<!-- Card grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
for _, t := range teams.Items {
|
for _, t := range teams.Items {
|
||||||
<div
|
<a
|
||||||
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0 transition-colors flex flex-col"
|
href={ templ.SafeURL(fmt.Sprintf("/teams/%d", t.ID)) }
|
||||||
|
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0
|
||||||
|
transition-colors flex flex-col hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
<!-- Header: Name with color indicator -->
|
<!-- Header: Name with color indicator -->
|
||||||
<div class="flex justify-between items-start mb-3">
|
<div class="flex justify-between items-start mb-3">
|
||||||
@@ -102,7 +105,7 @@ templ TeamsList(teams *db.List[db.Team]) {
|
|||||||
{ t.AltShortName }
|
{ t.AltShortName }
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Pagination controls -->
|
<!-- Pagination controls -->
|
||||||
|
|||||||
87
justfile
87
justfile
@@ -14,7 +14,7 @@ default:
|
|||||||
|
|
||||||
# Build the target binary
|
# Build the target binary
|
||||||
[group('build')]
|
[group('build')]
|
||||||
build target=entrypoint: tailwind (_build target)
|
build target=entrypoint: templ tailwind (_build target)
|
||||||
|
|
||||||
_build target=entrypoint: tidy (generate target)
|
_build target=entrypoint: tidy (generate target)
|
||||||
go build -ldflags="-w -s" -o {{bin}}/{{target}} {{cmd}}/{{target}}
|
go build -ldflags="-w -s" -o {{bin}}/{{target}} {{cmd}}/{{target}}
|
||||||
@@ -122,3 +122,88 @@ _migrate-new name: && _build _migrate-status
|
|||||||
reset-db env='.env': _build
|
reset-db env='.env': _build
|
||||||
echo "⚠️ WARNING - This will DELETE ALL DATA!"
|
echo "⚠️ WARNING - This will DELETE ALL DATA!"
|
||||||
{{bin}}/{{entrypoint}} --reset-db --envfile {{env}}
|
{{bin}}/{{entrypoint}} --reset-db --envfile {{env}}
|
||||||
|
|
||||||
|
# Restore database from a production backup (.sql)
|
||||||
|
[group('db')]
|
||||||
|
[confirm("⚠️ This will DELETE ALL DATA in the dev database and replace it with the backup. Continue?")]
|
||||||
|
[script]
|
||||||
|
restore-db backup_file env='.env':
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Source env vars
|
||||||
|
set -a
|
||||||
|
source ./{{env}}
|
||||||
|
set +a
|
||||||
|
|
||||||
|
DB_USER="${DB_USER}"
|
||||||
|
DB_PASSWORD="${DB_PASSWORD}"
|
||||||
|
DB_HOST="${DB_HOST}"
|
||||||
|
DB_PORT="${DB_PORT:-5432}"
|
||||||
|
DB_NAME="${DB_NAME}"
|
||||||
|
PROD_USER="oslstats"
|
||||||
|
|
||||||
|
export PGPASSWORD="$DB_PASSWORD"
|
||||||
|
|
||||||
|
echo "[INFO] Restoring database from: {{backup_file}}"
|
||||||
|
echo "[INFO] Target: $DB_NAME on $DB_HOST:$DB_PORT as $DB_USER"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 1: Drop and recreate the database
|
||||||
|
echo "[INFO] Step 1/4: Dropping and recreating database..."
|
||||||
|
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c \
|
||||||
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();" \
|
||||||
|
> /dev/null 2>&1 || true
|
||||||
|
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS \"$DB_NAME\";"
|
||||||
|
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "CREATE DATABASE \"$DB_NAME\" OWNER \"$DB_USER\";"
|
||||||
|
echo "[INFO] Database recreated"
|
||||||
|
|
||||||
|
# Step 2: Preprocess and restore the dump (remap ownership)
|
||||||
|
echo "[INFO] Step 2/4: Restoring backup (remapping owner $PROD_USER → $DB_USER)..."
|
||||||
|
sed \
|
||||||
|
-e "s/OWNER TO ${PROD_USER}/OWNER TO ${DB_USER}/g" \
|
||||||
|
-e "s/Owner: ${PROD_USER}/Owner: ${DB_USER}/g" \
|
||||||
|
-e "/^ALTER DEFAULT PRIVILEGES/d" \
|
||||||
|
-e "s/GRANT ALL ON \(.*\) TO ${PROD_USER}/GRANT ALL ON \1 TO ${DB_USER}/g" \
|
||||||
|
"{{backup_file}}" | psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" --quiet --single-transaction
|
||||||
|
echo "[INFO] Backup restored"
|
||||||
|
|
||||||
|
# Step 3: Reassign all ownership as safety net
|
||||||
|
echo "[INFO] Step 3/4: Reassigning remaining ownership to $DB_USER..."
|
||||||
|
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" <<EOSQL
|
||||||
|
DO \$\$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
-- Reassign tables
|
||||||
|
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE public.%I OWNER TO ${DB_USER}', r.tablename);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Reassign sequences
|
||||||
|
FOR r IN SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER SEQUENCE public.%I OWNER TO ${DB_USER}', r.sequence_name);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Reassign views
|
||||||
|
FOR r IN SELECT viewname FROM pg_views WHERE schemaname = 'public'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER VIEW public.%I OWNER TO ${DB_USER}', r.viewname);
|
||||||
|
END LOOP;
|
||||||
|
END \$\$;
|
||||||
|
EOSQL
|
||||||
|
echo "[INFO] Ownership reassigned"
|
||||||
|
|
||||||
|
# Step 4: Summary
|
||||||
|
echo "[INFO] Step 4/4: Verifying table count..."
|
||||||
|
TABLE_COUNT=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c \
|
||||||
|
"SELECT count(*) FROM pg_tables WHERE schemaname = 'public';")
|
||||||
|
echo "[INFO] Found${TABLE_COUNT} tables in restored database"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Database restored successfully from: {{backup_file}}"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Next steps:"
|
||||||
|
echo " 1. Run 'just migrate up all' to apply any dev-only migrations"
|
||||||
|
echo " 2. Run 'just dev' to start the development server"
|
||||||
|
|||||||
Reference in New Issue
Block a user