Compare commits

...

9 Commits

36 changed files with 7274 additions and 149 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.env
*.env
*.db*
.logs/
server.log
@@ -10,6 +11,7 @@ internal/view/**/*_templ.go
internal/view/**/*_templ.txt
cmd/test/*
.opencode
prod-export.sql
# Database backups (compressed)
backups/*.sql.gz

View File

@@ -513,6 +513,7 @@ func GetAllLeaguePlayerStats(
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)
@@ -583,6 +584,7 @@ func GetTopGoalScorers(
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)
@@ -655,6 +657,7 @@ func GetTopAssisters(
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)
@@ -727,6 +730,7 @@ func GetTopSavers(
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)

View 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
},
)
}

View File

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

547
internal/db/playoff.go Normal file
View 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
}

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

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

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

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

View File

@@ -142,7 +142,12 @@ type LeagueWithTeams struct {
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 {
now := time.Now()
@@ -150,20 +155,32 @@ func (s *Season) GetStatus() SeasonStatus {
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.FinalsEndDate.IsZero() && now.After(s.FinalsEndDate.Time) {
if !s.FinalsEndDate.IsZero() && dayPassed(s.FinalsEndDate.Time) {
return StatusCompleted
}
if now.After(s.FinalsStartDate.Time) {
if dayStarted(s.FinalsStartDate.Time) {
return StatusFinals
}
if !s.EndDate.IsZero() && now.After(s.EndDate.Time) {
if !s.EndDate.IsZero() && dayPassed(s.EndDate.Time) {
return StatusFinalsSoon
}
return StatusInProgress
}
if !s.EndDate.IsZero() && now.After(s.EndDate.Time) {
if !s.EndDate.IsZero() && dayPassed(s.EndDate.Time) {
return StatusCompleted
}

View File

@@ -38,6 +38,9 @@ func (db *DB) RegisterModels() []any {
(*Player)(nil),
(*FixtureResult)(nil),
(*FixtureResultPlayerStats)(nil),
(*PlayoffBracket)(nil),
(*PlayoffSeries)(nil),
(*PlayoffMatch)(nil),
}
db.RegisterModel(models...)
return models

View File

@@ -267,9 +267,6 @@
.top-0 {
top: calc(var(--spacing) * 0);
}
.top-1 {
top: calc(var(--spacing) * 1);
}
.top-1\/2 {
top: calc(1 / 2 * 100%);
}
@@ -330,6 +327,9 @@
max-width: 96rem;
}
}
.mx-1 {
margin-inline: calc(var(--spacing) * 1);
}
.mx-auto {
margin-inline: auto;
}
@@ -339,9 +339,6 @@
.-mt-3 {
margin-top: calc(var(--spacing) * -3);
}
.mt-0 {
margin-top: calc(var(--spacing) * 0);
}
.mt-0\.5 {
margin-top: calc(var(--spacing) * 0.5);
}
@@ -375,9 +372,6 @@
.mt-12 {
margin-top: calc(var(--spacing) * 12);
}
.mt-16 {
margin-top: calc(var(--spacing) * 16);
}
.mt-24 {
margin-top: calc(var(--spacing) * 24);
}
@@ -454,6 +448,9 @@
width: calc(var(--spacing) * 5);
height: calc(var(--spacing) * 5);
}
.h-0 {
height: calc(var(--spacing) * 0);
}
.h-1 {
height: calc(var(--spacing) * 1);
}
@@ -475,6 +472,9 @@
.h-9 {
height: calc(var(--spacing) * 9);
}
.h-10 {
height: calc(var(--spacing) * 10);
}
.h-12 {
height: calc(var(--spacing) * 12);
}
@@ -493,6 +493,9 @@
.h-screen {
height: 100vh;
}
.max-h-40 {
max-height: calc(var(--spacing) * 40);
}
.max-h-60 {
max-height: calc(var(--spacing) * 60);
}
@@ -556,9 +559,15 @@
.w-14 {
width: calc(var(--spacing) * 14);
}
.w-16 {
width: calc(var(--spacing) * 16);
}
.w-20 {
width: calc(var(--spacing) * 20);
}
.w-24 {
width: calc(var(--spacing) * 24);
}
.w-26 {
width: calc(var(--spacing) * 26);
}
@@ -631,25 +640,21 @@
.min-w-0 {
min-width: calc(var(--spacing) * 0);
}
.min-w-\[500px\] {
min-width: 500px;
}
.min-w-\[700px\] {
min-width: 700px;
}
.flex-1 {
flex: 1;
}
.flex-shrink {
flex-shrink: 1;
}
.flex-shrink-0 {
flex-shrink: 0;
}
.shrink-0 {
flex-shrink: 0;
}
.border-collapse {
border-collapse: collapse;
}
.-translate-y-1 {
--tw-translate-y: calc(var(--spacing) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -674,6 +679,9 @@
--tw-scale-z: 100%;
scale: var(--tw-scale-x) var(--tw-scale-y);
}
.rotate-180 {
rotate: 180deg;
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
@@ -959,6 +967,10 @@
border-top-style: var(--tw-border-style);
border-top-width: 1px;
}
.border-t-2 {
border-top-style: var(--tw-border-style);
border-top-width: 2px;
}
.border-b {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
@@ -992,9 +1004,6 @@
.border-overlay0 {
border-color: var(--overlay0);
}
.border-peach {
border-color: var(--peach);
}
.border-peach\/50 {
border-color: var(--peach);
@supports (color: color-mix(in lab, red, red)) {
@@ -1088,6 +1097,12 @@
.bg-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)) {
@@ -1112,9 +1127,6 @@
.bg-mauve {
background-color: var(--mauve);
}
.bg-overlay0 {
background-color: var(--overlay0);
}
.bg-overlay0\/10 {
background-color: var(--overlay0);
@supports (color: color-mix(in lab, red, red)) {
@@ -1262,9 +1274,6 @@
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
.py-0 {
padding-block: calc(var(--spacing) * 0);
}
.py-0\.5 {
padding-block: calc(var(--spacing) * 0.5);
}
@@ -1557,10 +1566,6 @@
--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.blur {
--tw-blur: blur(8px);
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
@@ -1588,6 +1593,11 @@
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
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 {
--tw-duration: 150ms;
transition-duration: 150ms;
@@ -1865,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 {
@media (hover: hover) {
@@ -2416,6 +2436,16 @@
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 {
@media (width >= 64rem) {
padding-inline: calc(var(--spacing) * 8);
@@ -2685,11 +2715,6 @@
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-blur {
syntax: "*";
inherits: false;
@@ -2792,7 +2817,6 @@
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-outline-style: solid;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;

View File

@@ -0,0 +1,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;
})();

View File

@@ -2,17 +2,27 @@ package handlers
import (
"context"
"fmt"
"net/http"
"git.haelnorr.com/h/golib/hws"
"strconv"
"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/oslstats/internal/view/seasonsview"
"git.haelnorr.com/h/timefmt"
"github.com/pkg/errors"
"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(
s *hws.Server,
conn *db.DB,
@@ -21,11 +31,17 @@ func SeasonLeagueFinalsPage(
seasonStr := r.PathValue("season_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) {
var err error
sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
@@ -33,15 +49,286 @@ func SeasonLeagueFinalsPage(
}
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
}); !ok {
return
}
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 {
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
}

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

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

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

View File

@@ -40,6 +40,9 @@ const (
FixturesCreate Permission = "fixtures.create"
FixturesDelete Permission = "fixtures.delete"
// Playoffs permissions
PlayoffsManage Permission = "playoffs.manage"
// Free Agent permissions
FreeAgentsAdd Permission = "free_agents.add"
FreeAgentsRemove Permission = "free_agents.remove"

View File

@@ -124,6 +124,9 @@ func refreshToken(
case "expired", "expiring":
newtoken, err := discordAPI.RefreshToken(token.Convert())
if err != nil {
if strings.Contains(err.Error(), "invalid_grant") {
return false, nil
}
return false, errors.Wrap(err, "discordAPI.RefreshToken")
}
err = user.UpdateDiscordToken(ctx, tx, newtoken)

View File

@@ -137,6 +137,16 @@ func addRoutes(
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
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}",
Method: hws.MethodPOST,
@@ -319,6 +329,94 @@ func addRoutes(
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{

View 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">&#9733;</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>
}

View File

@@ -520,32 +520,6 @@ templ fixtureResultDisplay(fixture *db.Fixture, result *db.FixtureResult) {
}
templ fixtureUploadPrompt(fixture *db.Fixture) {
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
<div class="text-4xl mb-3">📋</div>
<p class="text-lg text-text font-medium mb-2">No Result Uploaded</p>
<p class="text-sm text-subtext1 mb-4">Upload match log files to record the result of this fixture.</p>
<div class="flex items-center justify-center gap-3">
<a
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/results/upload", fixture.ID)) }
class="inline-block px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Upload Match Logs
</a>
<button
type="button"
@click="$dispatch('open-forfeit-modal')"
class="inline-block px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Forfeit Match
</button>
</div>
</div>
@forfeitModal(fixture)
}
templ forfeitModal(fixture *db.Fixture) {
<div
x-data="{
open: false,
@@ -553,7 +527,35 @@ templ forfeitModal(fixture *db.Fixture) {
forfeitTeam: '',
forfeitReason: '',
}"
@open-forfeit-modal.window="open = true"
>
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
<div class="text-4xl mb-3">📋</div>
<p class="text-lg text-text font-medium mb-2">No Result Uploaded</p>
<p class="text-sm text-subtext1 mb-4">Upload match log files to record the result of this fixture.</p>
<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>
}
templ forfeitModal(fixture *db.Fixture) {
<div
x-show="open"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
@@ -1203,7 +1205,7 @@ templ fixtureScheduleStatus(
<div class="flex justify-center mt-4">
<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
font-medium transition hover:cursor-pointer"
>
@@ -1430,7 +1432,7 @@ templ fixtureScheduleActions(
</div>
<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
font-medium transition hover:cursor-pointer"
>
@@ -1457,7 +1459,7 @@ templ fixtureScheduleActions(
</div>
<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
font-medium transition hover:cursor-pointer"
>

View File

@@ -160,14 +160,14 @@ templ FixtureReviewResultPage(
Finalize Result
</button>
</form>
<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) }
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>
<button
type="button"
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
font-medium transition hover:cursor-pointer text-lg"
>
Discard & Re-upload
</button>
</div>
</div>
</div>

View File

@@ -35,7 +35,7 @@ templ LeaguesSection(season *db.Season, allLeagues []*db.League) {
if canRemoveLeague {
<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"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -115,29 +115,28 @@ templ SeasonsList(seasons *db.List[db.Season]) {
}
</div>
}
<!-- Date Info -->
{{
now := time.Now()
}}
<div class="text-xs text-subtext1 mt-auto">
if now.Before(s.StartDate) {
<!-- Date Info -->
{{
listStatus := s.GetStatus()
}}
<div class="text-xs text-subtext1 mt-auto">
switch listStatus {
case db.StatusUpcoming:
Starts: { formatDate(s.StartDate) }
} else if !s.FinalsStartDate.IsZero() {
// Finals are scheduled
if !s.FinalsEndDate.IsZero() && now.After(s.FinalsEndDate.Time) {
case db.StatusCompleted:
if !s.FinalsEndDate.IsZero() {
Completed: { formatDate(s.FinalsEndDate.Time) }
} else if now.After(s.FinalsStartDate.Time) {
Finals Started: { formatDate(s.FinalsStartDate.Time) }
} else {
Finals Start: { formatDate(s.FinalsStartDate.Time) }
} else if !s.EndDate.IsZero() {
Completed: { formatDate(s.EndDate.Time) }
}
} else if !s.EndDate.IsZero() && now.After(s.EndDate.Time) {
// No finals scheduled and regular season ended
Completed: { formatDate(s.EndDate.Time) }
} else {
case db.StatusFinals:
Finals Started: { formatDate(s.FinalsStartDate.Time) }
case db.StatusFinalsSoon:
Finals Start: { formatDate(s.FinalsStartDate.Time) }
default:
Started: { formatDate(s.StartDate) }
}
</div>
}
</div>
</a>
}
</div>

View 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">&#9733;</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>
}
}

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

View File

@@ -1,15 +1,116 @@
package seasonsview
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) {
@SeasonLeagueFinals()
@SeasonLeagueFinals(season, league, bracket, topGoals, topAssists, topSaves, allStats)
}
}
templ SeasonLeagueFinals() {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">Coming Soon...</p>
templ SeasonLeagueFinals(
season *db.Season,
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">&#9733;</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>
}

View 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">&#127942;</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">&#127942;</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">
&#9733; 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>
}

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

View 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">
&#9733;
</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>
}

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

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

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

View File

@@ -1,7 +1,6 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "time"
// StatusBadge renders a season status badge
// Parameters:
@@ -10,53 +9,34 @@ import "time"
// - useShortLabels: bool - true for "Active/Finals", false for "In Progress/Finals in Progress"
templ StatusBadge(season *db.Season, compact bool, useShortLabels bool) {
{{
now := time.Now()
seasonStatus := season.GetStatus()
status := ""
statusBg := ""
// Determine status based on dates
if now.Before(season.StartDate) {
switch seasonStatus {
case db.StatusUpcoming:
status = "Upcoming"
statusBg = "bg-blue"
} else if !season.FinalsStartDate.IsZero() {
// 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
case db.StatusInProgress:
if useShortLabels {
status = "Active"
} else {
status = "In Progress"
}
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 }>

View File

@@ -122,3 +122,88 @@ _migrate-new name: && _build _migrate-status
reset-db env='.env': _build
echo "⚠️ WARNING - This will DELETE ALL DATA!"
{{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"