finals generation added
This commit is contained in:
98
internal/db/migrations/20260308140000_add_playoffs.go
Normal file
98
internal/db/migrations/20260308140000_add_playoffs.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Migrations.MustRegister(
|
||||||
|
// UP migration
|
||||||
|
func(ctx context.Context, conn *bun.DB) error {
|
||||||
|
// Create playoff_brackets table
|
||||||
|
_, err := conn.NewCreateTable().
|
||||||
|
Model((*db.PlayoffBracket)(nil)).
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create playoff_series table
|
||||||
|
_, err = conn.NewCreateTable().
|
||||||
|
Model((*db.PlayoffSeries)(nil)).
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create playoff_matches table
|
||||||
|
_, err = conn.NewCreateTable().
|
||||||
|
Model((*db.PlayoffMatch)(nil)).
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add foreign key for winner_next_series_id
|
||||||
|
_, err = conn.NewRaw(`
|
||||||
|
ALTER TABLE playoff_series
|
||||||
|
ADD CONSTRAINT fk_winner_next_series
|
||||||
|
FOREIGN KEY (winner_next_series_id) REFERENCES playoff_series(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
`).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add foreign key for loser_next_series_id
|
||||||
|
_, err = conn.NewRaw(`
|
||||||
|
ALTER TABLE playoff_series
|
||||||
|
ADD CONSTRAINT fk_loser_next_series
|
||||||
|
FOREIGN KEY (loser_next_series_id) REFERENCES playoff_series(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
`).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// DOWN migration
|
||||||
|
func(ctx context.Context, conn *bun.DB) error {
|
||||||
|
// Drop tables in reverse order (respecting foreign keys)
|
||||||
|
_, err := conn.NewDropTable().
|
||||||
|
Model((*db.PlayoffMatch)(nil)).
|
||||||
|
IfExists().
|
||||||
|
Cascade().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = conn.NewDropTable().
|
||||||
|
Model((*db.PlayoffSeries)(nil)).
|
||||||
|
IfExists().
|
||||||
|
Cascade().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = conn.NewDropTable().
|
||||||
|
Model((*db.PlayoffBracket)(nil)).
|
||||||
|
IfExists().
|
||||||
|
Cascade().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
326
internal/db/playoff.go
Normal file
326
internal/db/playoff.go
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
531
internal/db/playoff_generation.go
Normal file
531
internal/db/playoff_generation.go
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
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 (QF1-QF4): Top 4 get second chance
|
||||||
|
// QF1: 1st vs 4th
|
||||||
|
// QF2: 2nd vs 3rd
|
||||||
|
// QF3: 5th vs 8th
|
||||||
|
// QF4: 6th vs 7th
|
||||||
|
//
|
||||||
|
// Semi Finals:
|
||||||
|
// SF1: Loser(QF1) vs Winner(QF4) — loser eliminated
|
||||||
|
// SF2: Loser(QF2) vs Winner(QF3) — loser eliminated
|
||||||
|
//
|
||||||
|
// Preliminary Finals:
|
||||||
|
// 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(EF2)
|
||||||
|
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(EF1)
|
||||||
|
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 -> SF2 (team2), Loser eliminated
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, qf3.ID,
|
||||||
|
&sf2.ID, strPtr("team2"), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire EF1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EF2 (QF4): Winner -> SF1 (team2), Loser eliminated
|
||||||
|
err = SetSeriesAdvancement(ctx, tx, qf4.ID,
|
||||||
|
&sf1.ID, strPtr("team2"), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "wire EF2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SF1: Winner -> PF2 (team2), Loser eliminated
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ func (db *DB) RegisterModels() []any {
|
|||||||
(*Player)(nil),
|
(*Player)(nil),
|
||||||
(*FixtureResult)(nil),
|
(*FixtureResult)(nil),
|
||||||
(*FixtureResultPlayerStats)(nil),
|
(*FixtureResultPlayerStats)(nil),
|
||||||
|
(*PlayoffBracket)(nil),
|
||||||
|
(*PlayoffSeries)(nil),
|
||||||
|
(*PlayoffMatch)(nil),
|
||||||
}
|
}
|
||||||
db.RegisterModel(models...)
|
db.RegisterModel(models...)
|
||||||
return models
|
return models
|
||||||
|
|||||||
@@ -267,9 +267,6 @@
|
|||||||
.top-0 {
|
.top-0 {
|
||||||
top: calc(var(--spacing) * 0);
|
top: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
.top-1 {
|
|
||||||
top: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.top-1\/2 {
|
.top-1\/2 {
|
||||||
top: calc(1 / 2 * 100%);
|
top: calc(1 / 2 * 100%);
|
||||||
}
|
}
|
||||||
@@ -339,9 +336,6 @@
|
|||||||
.-mt-3 {
|
.-mt-3 {
|
||||||
margin-top: calc(var(--spacing) * -3);
|
margin-top: calc(var(--spacing) * -3);
|
||||||
}
|
}
|
||||||
.mt-0 {
|
|
||||||
margin-top: calc(var(--spacing) * 0);
|
|
||||||
}
|
|
||||||
.mt-0\.5 {
|
.mt-0\.5 {
|
||||||
margin-top: calc(var(--spacing) * 0.5);
|
margin-top: calc(var(--spacing) * 0.5);
|
||||||
}
|
}
|
||||||
@@ -375,9 +369,6 @@
|
|||||||
.mt-12 {
|
.mt-12 {
|
||||||
margin-top: calc(var(--spacing) * 12);
|
margin-top: calc(var(--spacing) * 12);
|
||||||
}
|
}
|
||||||
.mt-16 {
|
|
||||||
margin-top: calc(var(--spacing) * 16);
|
|
||||||
}
|
|
||||||
.mt-24 {
|
.mt-24 {
|
||||||
margin-top: calc(var(--spacing) * 24);
|
margin-top: calc(var(--spacing) * 24);
|
||||||
}
|
}
|
||||||
@@ -493,6 +484,9 @@
|
|||||||
.h-screen {
|
.h-screen {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
.max-h-40 {
|
||||||
|
max-height: calc(var(--spacing) * 40);
|
||||||
|
}
|
||||||
.max-h-60 {
|
.max-h-60 {
|
||||||
max-height: calc(var(--spacing) * 60);
|
max-height: calc(var(--spacing) * 60);
|
||||||
}
|
}
|
||||||
@@ -559,6 +553,9 @@
|
|||||||
.w-20 {
|
.w-20 {
|
||||||
width: calc(var(--spacing) * 20);
|
width: calc(var(--spacing) * 20);
|
||||||
}
|
}
|
||||||
|
.w-24 {
|
||||||
|
width: calc(var(--spacing) * 24);
|
||||||
|
}
|
||||||
.w-26 {
|
.w-26 {
|
||||||
width: calc(var(--spacing) * 26);
|
width: calc(var(--spacing) * 26);
|
||||||
}
|
}
|
||||||
@@ -634,22 +631,12 @@
|
|||||||
.flex-1 {
|
.flex-1 {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.flex-shrink {
|
|
||||||
flex-shrink: 1;
|
|
||||||
}
|
|
||||||
.flex-shrink-0 {
|
.flex-shrink-0 {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.shrink-0 {
|
.shrink-0 {
|
||||||
flex-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 {
|
.-translate-y-1\/2 {
|
||||||
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
|
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
@@ -758,9 +745,6 @@
|
|||||||
.justify-end {
|
.justify-end {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
.gap-0 {
|
|
||||||
gap: calc(var(--spacing) * 0);
|
|
||||||
}
|
|
||||||
.gap-0\.5 {
|
.gap-0\.5 {
|
||||||
gap: calc(var(--spacing) * 0.5);
|
gap: calc(var(--spacing) * 0.5);
|
||||||
}
|
}
|
||||||
@@ -992,9 +976,6 @@
|
|||||||
.border-overlay0 {
|
.border-overlay0 {
|
||||||
border-color: var(--overlay0);
|
border-color: var(--overlay0);
|
||||||
}
|
}
|
||||||
.border-peach {
|
|
||||||
border-color: var(--peach);
|
|
||||||
}
|
|
||||||
.border-peach\/50 {
|
.border-peach\/50 {
|
||||||
border-color: var(--peach);
|
border-color: var(--peach);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -1088,6 +1069,12 @@
|
|||||||
.bg-green {
|
.bg-green {
|
||||||
background-color: var(--green);
|
background-color: var(--green);
|
||||||
}
|
}
|
||||||
|
.bg-green\/5 {
|
||||||
|
background-color: var(--green);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--green) 5%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
.bg-green\/10 {
|
.bg-green\/10 {
|
||||||
background-color: var(--green);
|
background-color: var(--green);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -1112,9 +1099,6 @@
|
|||||||
.bg-mauve {
|
.bg-mauve {
|
||||||
background-color: var(--mauve);
|
background-color: var(--mauve);
|
||||||
}
|
}
|
||||||
.bg-overlay0 {
|
|
||||||
background-color: var(--overlay0);
|
|
||||||
}
|
|
||||||
.bg-overlay0\/10 {
|
.bg-overlay0\/10 {
|
||||||
background-color: var(--overlay0);
|
background-color: var(--overlay0);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -1262,9 +1246,6 @@
|
|||||||
.px-6 {
|
.px-6 {
|
||||||
padding-inline: calc(var(--spacing) * 6);
|
padding-inline: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
.py-0 {
|
|
||||||
padding-block: calc(var(--spacing) * 0);
|
|
||||||
}
|
|
||||||
.py-0\.5 {
|
.py-0\.5 {
|
||||||
padding-block: calc(var(--spacing) * 0.5);
|
padding-block: calc(var(--spacing) * 0.5);
|
||||||
}
|
}
|
||||||
@@ -1557,10 +1538,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));
|
--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);
|
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 {
|
.blur {
|
||||||
--tw-blur: blur(8px);
|
--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,);
|
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,);
|
||||||
@@ -1865,6 +1842,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:bg-surface0\/50 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--surface0);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--surface0) 50%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-surface1 {
|
.hover\:bg-surface1 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -2685,11 +2672,6 @@
|
|||||||
inherits: false;
|
inherits: false;
|
||||||
initial-value: 0 0 #0000;
|
initial-value: 0 0 #0000;
|
||||||
}
|
}
|
||||||
@property --tw-outline-style {
|
|
||||||
syntax: "*";
|
|
||||||
inherits: false;
|
|
||||||
initial-value: solid;
|
|
||||||
}
|
|
||||||
@property --tw-blur {
|
@property --tw-blur {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
@@ -2792,7 +2774,6 @@
|
|||||||
--tw-ring-offset-width: 0px;
|
--tw-ring-offset-width: 0px;
|
||||||
--tw-ring-offset-color: #fff;
|
--tw-ring-offset-color: #fff;
|
||||||
--tw-ring-offset-shadow: 0 0 #0000;
|
--tw-ring-offset-shadow: 0 0 #0000;
|
||||||
--tw-outline-style: solid;
|
|
||||||
--tw-blur: initial;
|
--tw-blur: initial;
|
||||||
--tw-brightness: initial;
|
--tw-brightness: initial;
|
||||||
--tw-contrast: initial;
|
--tw-contrast: initial;
|
||||||
|
|||||||
@@ -2,17 +2,27 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/respond"
|
||||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||||
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||||
|
"git.haelnorr.com/h/timefmt"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SeasonLeagueFinalsPage renders the finals tab of a season league page
|
// SeasonLeagueFinalsPage renders the finals tab of a season league page.
|
||||||
|
// Displays different content based on season status:
|
||||||
|
// - In Progress: "Regular Season in Progress" with optional "Begin Finals" button
|
||||||
|
// - Finals Soon/Finals/Completed: The playoff bracket
|
||||||
func SeasonLeagueFinalsPage(
|
func SeasonLeagueFinalsPage(
|
||||||
s *hws.Server,
|
s *hws.Server,
|
||||||
conn *db.DB,
|
conn *db.DB,
|
||||||
@@ -21,11 +31,13 @@ func SeasonLeagueFinalsPage(
|
|||||||
seasonStr := r.PathValue("season_short_name")
|
seasonStr := r.PathValue("season_short_name")
|
||||||
leagueStr := r.PathValue("league_short_name")
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var sl *db.SeasonLeague
|
var season *db.Season
|
||||||
|
var league *db.League
|
||||||
|
var bracket *db.PlayoffBracket
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -33,15 +45,266 @@ func SeasonLeagueFinalsPage(
|
|||||||
}
|
}
|
||||||
return false, errors.Wrap(err, "db.GetSeasonLeague")
|
return false, errors.Wrap(err, "db.GetSeasonLeague")
|
||||||
}
|
}
|
||||||
|
season = sl.Season
|
||||||
|
league = sl.League
|
||||||
|
|
||||||
|
// Try to load existing bracket
|
||||||
|
bracket, err = db.GetPlayoffBracket(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayoffBracket")
|
||||||
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueFinalsPage(sl.Season, sl.League), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league, bracket), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueFinals(), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFinals(season, league, bracket), 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ const (
|
|||||||
FixturesCreate Permission = "fixtures.create"
|
FixturesCreate Permission = "fixtures.create"
|
||||||
FixturesDelete Permission = "fixtures.delete"
|
FixturesDelete Permission = "fixtures.delete"
|
||||||
|
|
||||||
|
// Playoffs permissions
|
||||||
|
PlayoffsManage Permission = "playoffs.manage"
|
||||||
|
|
||||||
// Free Agent permissions
|
// Free Agent permissions
|
||||||
FreeAgentsAdd Permission = "free_agents.add"
|
FreeAgentsAdd Permission = "free_agents.add"
|
||||||
FreeAgentsRemove Permission = "free_agents.remove"
|
FreeAgentsRemove Permission = "free_agents.remove"
|
||||||
|
|||||||
@@ -137,6 +137,16 @@ func addRoutes(
|
|||||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
Handler: handlers.SeasonLeagueFinalsPage(s, conn),
|
Handler: handlers.SeasonLeagueFinalsPage(s, conn),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/finals/setup",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeasonLeagueFinalsSetupForm(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/finals/setup",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeasonLeagueFinalsSetupSubmit(s, conn)),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Path: "/seasons/{season_short_name}/add-league/{league_short_name}",
|
Path: "/seasons/{season_short_name}/add-league/{league_short_name}",
|
||||||
Method: hws.MethodPOST,
|
Method: hws.MethodPOST,
|
||||||
|
|||||||
268
internal/view/seasonsview/finals_setup_form.templ
Normal file
268
internal/view/seasonsview/finals_setup_form.templ
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div
|
||||||
|
class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"
|
||||||
|
x-data={ fmt.Sprintf("{ selectedFormat: '%s' }", defaultFormat) }
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-mantle border-b border-surface1 px-6 py-4">
|
||||||
|
<h2 class="text-xl font-bold text-text flex items-center gap-2">
|
||||||
|
<span class="text-yellow">★</span>
|
||||||
|
Begin Finals Setup
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-subtext0 mt-1">
|
||||||
|
Configure playoff format and dates for { league.Name }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/seasons/%s/leagues/%s/finals/setup", season.ShortName, league.ShortName) }
|
||||||
|
hx-swap="none"
|
||||||
|
class="p-6 space-y-6"
|
||||||
|
>
|
||||||
|
<!-- Date Fields -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
@datepicker.DatePicker(
|
||||||
|
"regular_season_end_date",
|
||||||
|
"regular_season_end_date",
|
||||||
|
"Regular Season End Date",
|
||||||
|
"DD/MM/YYYY",
|
||||||
|
true,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
<p class="text-xs text-subtext0 mt-1">Games after this date will be forfeited</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
@datepicker.DatePicker(
|
||||||
|
"finals_start_date",
|
||||||
|
"finals_start_date",
|
||||||
|
"Finals Start Date",
|
||||||
|
"DD/MM/YYYY",
|
||||||
|
true,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
<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>
|
||||||
|
}
|
||||||
165
internal/view/seasonsview/playoff_bracket.templ
Normal file
165
internal/view/seasonsview/playoff_bracket.templ
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// PlayoffBracketView renders the full bracket visualization
|
||||||
|
templ PlayoffBracketView(season *db.Season, league *db.League, bracket *db.PlayoffBracket) {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Bracket Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-text flex items-center gap-2">
|
||||||
|
<span class="text-yellow">★</span>
|
||||||
|
Finals Bracket
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-subtext0 mt-1">
|
||||||
|
{ formatLabel(bracket.Format) }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
@playoffStatusBadge(bracket.Status)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Bracket Series List -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
@bracketRounds(season, league, bracket)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// bracketRounds groups series by round and renders them
|
||||||
|
templ bracketRounds(season *db.Season, league *db.League, bracket *db.PlayoffBracket) {
|
||||||
|
{{
|
||||||
|
// Group series by round
|
||||||
|
rounds := groupSeriesByRound(bracket.Series)
|
||||||
|
roundOrder := getRoundOrder(bracket.Format)
|
||||||
|
}}
|
||||||
|
for _, roundName := range roundOrder {
|
||||||
|
if series, ok := rounds[roundName]; ok {
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="text-sm font-semibold text-subtext0 uppercase tracking-wider">
|
||||||
|
{ formatRoundName(roundName) }
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
for _, s := range series {
|
||||||
|
@seriesCard(season, league, s)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) {
|
||||||
|
<div 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) }>
|
||||||
|
<!-- Series Header -->
|
||||||
|
<div class="bg-mantle px-4 py-2 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-4 py-1.5 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-4 py-2.5",
|
||||||
|
templ.KV("bg-green/5", isWinner) }>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
if seed != nil {
|
||||||
|
<span class="text-xs font-mono text-subtext0 w-5 text-right">
|
||||||
|
{ fmt.Sprint(*seed) }
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-xs font-mono text-subtext0 w-5 text-right">-</span>
|
||||||
|
}
|
||||||
|
if isTBD {
|
||||||
|
<span class="text-sm text-subtext1 italic">TBD</span>
|
||||||
|
} else {
|
||||||
|
@links.TeamLinkInSeason(team, season, league)
|
||||||
|
if isWinner {
|
||||||
|
<span class="text-green text-xs">✓</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if matchesToWin > 1 {
|
||||||
|
<span class={ "text-sm font-mono",
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
}
|
||||||
88
internal/view/seasonsview/playoff_helpers.go
Normal file
88
internal/view/seasonsview/playoff_helpers.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
|
||||||
|
// groupSeriesByRound groups playoff series by their round field
|
||||||
|
func groupSeriesByRound(series []*db.PlayoffSeries) map[string][]*db.PlayoffSeries {
|
||||||
|
grouped := make(map[string][]*db.PlayoffSeries)
|
||||||
|
for _, s := range series {
|
||||||
|
grouped[s.Round] = append(grouped[s.Round], s)
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRoundOrder returns the display order of rounds for a given format
|
||||||
|
func getRoundOrder(format db.PlayoffFormat) []string {
|
||||||
|
switch format {
|
||||||
|
case db.PlayoffFormat5to6:
|
||||||
|
return []string{
|
||||||
|
"upper_bracket",
|
||||||
|
"lower_bracket",
|
||||||
|
"upper_final",
|
||||||
|
"lower_final",
|
||||||
|
"grand_final",
|
||||||
|
}
|
||||||
|
case db.PlayoffFormat7to9:
|
||||||
|
return []string{
|
||||||
|
"quarter_final",
|
||||||
|
"semi_final",
|
||||||
|
"third_place",
|
||||||
|
"grand_final",
|
||||||
|
}
|
||||||
|
case db.PlayoffFormat10to15:
|
||||||
|
return []string{
|
||||||
|
"qualifying_final",
|
||||||
|
"elimination_final",
|
||||||
|
"semi_final",
|
||||||
|
"preliminary_final",
|
||||||
|
"third_place",
|
||||||
|
"grand_final",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatRoundName converts a round slug to a human-readable name
|
||||||
|
func formatRoundName(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 Finals"
|
||||||
|
case "semi_final":
|
||||||
|
return "Semi Finals"
|
||||||
|
case "qualifying_final":
|
||||||
|
return "Qualifying Finals"
|
||||||
|
case "elimination_final":
|
||||||
|
return "Elimination Finals"
|
||||||
|
case "preliminary_final":
|
||||||
|
return "Preliminary Finals"
|
||||||
|
case "third_place":
|
||||||
|
return "Third Place Playoff"
|
||||||
|
case "grand_final":
|
||||||
|
return "Grand Final"
|
||||||
|
default:
|
||||||
|
return round
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,59 @@
|
|||||||
package seasonsview
|
package seasonsview
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
templ SeasonLeagueFinalsPage(season *db.Season, league *db.League) {
|
templ SeasonLeagueFinalsPage(season *db.Season, league *db.League, bracket *db.PlayoffBracket) {
|
||||||
@SeasonLeagueLayout("finals", season, league) {
|
@SeasonLeagueLayout("finals", season, league) {
|
||||||
@SeasonLeagueFinals()
|
@SeasonLeagueFinals(season, league, bracket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SeasonLeagueFinals() {
|
templ SeasonLeagueFinals(season *db.Season, league *db.League, bracket *db.PlayoffBracket) {
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
{{
|
||||||
<p class="text-subtext0 text-lg">Coming Soon...</p>
|
status := season.GetStatus()
|
||||||
|
permCache := contexts.Permissions(ctx)
|
||||||
|
canManagePlayoffs := permCache != nil && permCache.HasPermission(permissions.PlayoffsManage)
|
||||||
|
}}
|
||||||
|
<div id="finals-content">
|
||||||
|
if bracket != nil {
|
||||||
|
@PlayoffBracketView(season, league, bracket)
|
||||||
|
} else if status == db.StatusInProgress || status == db.StatusUpcoming {
|
||||||
|
@finalsRegularSeasonInProgress(season, league, canManagePlayoffs)
|
||||||
|
} else {
|
||||||
|
@finalsNotConfigured()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ finalsRegularSeasonInProgress(season *db.Season, league *db.League, canManagePlayoffs bool) {
|
||||||
|
<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">Regular Season in Progress</p>
|
||||||
|
<p class="text-subtext0 mb-6">
|
||||||
|
Finals will be available once the regular season is complete.
|
||||||
|
</p>
|
||||||
|
if canManagePlayoffs {
|
||||||
|
<button
|
||||||
|
hx-get={ fmt.Sprintf("/seasons/%s/leagues/%s/finals/setup", season.ShortName, league.ShortName) }
|
||||||
|
hx-target="#finals-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="px-6 py-3 bg-blue hover:bg-blue/80 text-mantle rounded-lg font-semibold transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Begin Finals
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ finalsNotConfigured() {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No finals configured for this league.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
85
justfile
85
justfile
@@ -122,3 +122,88 @@ _migrate-new name: && _build _migrate-status
|
|||||||
reset-db env='.env': _build
|
reset-db env='.env': _build
|
||||||
echo "⚠️ WARNING - This will DELETE ALL DATA!"
|
echo "⚠️ WARNING - This will DELETE ALL DATA!"
|
||||||
{{bin}}/{{entrypoint}} --reset-db --envfile {{env}}
|
{{bin}}/{{entrypoint}} --reset-db --envfile {{env}}
|
||||||
|
|
||||||
|
# Restore database from a production backup (.sql)
|
||||||
|
[group('db')]
|
||||||
|
[confirm("⚠️ This will DELETE ALL DATA in the dev database and replace it with the backup. Continue?")]
|
||||||
|
[script]
|
||||||
|
restore-db backup_file env='.env':
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Source env vars
|
||||||
|
set -a
|
||||||
|
source ./{{env}}
|
||||||
|
set +a
|
||||||
|
|
||||||
|
DB_USER="${DB_USER}"
|
||||||
|
DB_PASSWORD="${DB_PASSWORD}"
|
||||||
|
DB_HOST="${DB_HOST}"
|
||||||
|
DB_PORT="${DB_PORT:-5432}"
|
||||||
|
DB_NAME="${DB_NAME}"
|
||||||
|
PROD_USER="oslstats"
|
||||||
|
|
||||||
|
export PGPASSWORD="$DB_PASSWORD"
|
||||||
|
|
||||||
|
echo "[INFO] Restoring database from: {{backup_file}}"
|
||||||
|
echo "[INFO] Target: $DB_NAME on $DB_HOST:$DB_PORT as $DB_USER"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 1: Drop and recreate the database
|
||||||
|
echo "[INFO] Step 1/4: Dropping and recreating database..."
|
||||||
|
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c \
|
||||||
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();" \
|
||||||
|
> /dev/null 2>&1 || true
|
||||||
|
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS \"$DB_NAME\";"
|
||||||
|
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "CREATE DATABASE \"$DB_NAME\" OWNER \"$DB_USER\";"
|
||||||
|
echo "[INFO] Database recreated"
|
||||||
|
|
||||||
|
# Step 2: Preprocess and restore the dump (remap ownership)
|
||||||
|
echo "[INFO] Step 2/4: Restoring backup (remapping owner $PROD_USER → $DB_USER)..."
|
||||||
|
sed \
|
||||||
|
-e "s/OWNER TO ${PROD_USER}/OWNER TO ${DB_USER}/g" \
|
||||||
|
-e "s/Owner: ${PROD_USER}/Owner: ${DB_USER}/g" \
|
||||||
|
-e "/^ALTER DEFAULT PRIVILEGES/d" \
|
||||||
|
-e "s/GRANT ALL ON \(.*\) TO ${PROD_USER}/GRANT ALL ON \1 TO ${DB_USER}/g" \
|
||||||
|
"{{backup_file}}" | psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" --quiet --single-transaction
|
||||||
|
echo "[INFO] Backup restored"
|
||||||
|
|
||||||
|
# Step 3: Reassign all ownership as safety net
|
||||||
|
echo "[INFO] Step 3/4: Reassigning remaining ownership to $DB_USER..."
|
||||||
|
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" <<EOSQL
|
||||||
|
DO \$\$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
-- Reassign tables
|
||||||
|
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE public.%I OWNER TO ${DB_USER}', r.tablename);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Reassign sequences
|
||||||
|
FOR r IN SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER SEQUENCE public.%I OWNER TO ${DB_USER}', r.sequence_name);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Reassign views
|
||||||
|
FOR r IN SELECT viewname FROM pg_views WHERE schemaname = 'public'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER VIEW public.%I OWNER TO ${DB_USER}', r.viewname);
|
||||||
|
END LOOP;
|
||||||
|
END \$\$;
|
||||||
|
EOSQL
|
||||||
|
echo "[INFO] Ownership reassigned"
|
||||||
|
|
||||||
|
# Step 4: Summary
|
||||||
|
echo "[INFO] Step 4/4: Verifying table count..."
|
||||||
|
TABLE_COUNT=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c \
|
||||||
|
"SELECT count(*) FROM pg_tables WHERE schemaname = 'public';")
|
||||||
|
echo "[INFO] Found${TABLE_COUNT} tables in restored database"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Database restored successfully from: {{backup_file}}"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Next steps:"
|
||||||
|
echo " 1. Run 'just migrate up all' to apply any dev-only migrations"
|
||||||
|
echo " 2. Run 'just dev' to start the development server"
|
||||||
|
|||||||
Reference in New Issue
Block a user