From 723a213be3765f724b79c4f39ad65b0bc2dbb207 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 8 Mar 2026 18:12:03 +1100 Subject: [PATCH] finals generation added --- .../migrations/20260308140000_add_playoffs.go | 98 ++++ internal/db/playoff.go | 326 +++++++++++ internal/db/playoff_generation.go | 531 ++++++++++++++++++ internal/db/setup.go | 3 + internal/embedfs/web/css/output.css | 63 +-- internal/handlers/season_league_finals.go | 273 ++++++++- internal/permissions/constants.go | 3 + internal/server/routes.go | 10 + .../view/seasonsview/finals_setup_form.templ | 268 +++++++++ .../view/seasonsview/playoff_bracket.templ | 165 ++++++ internal/view/seasonsview/playoff_helpers.go | 88 +++ .../seasonsview/season_league_finals.templ | 54 +- justfile | 85 +++ 13 files changed, 1916 insertions(+), 51 deletions(-) create mode 100644 internal/db/migrations/20260308140000_add_playoffs.go create mode 100644 internal/db/playoff.go create mode 100644 internal/db/playoff_generation.go create mode 100644 internal/view/seasonsview/finals_setup_form.templ create mode 100644 internal/view/seasonsview/playoff_bracket.templ create mode 100644 internal/view/seasonsview/playoff_helpers.go diff --git a/internal/db/migrations/20260308140000_add_playoffs.go b/internal/db/migrations/20260308140000_add_playoffs.go new file mode 100644 index 0000000..591d941 --- /dev/null +++ b/internal/db/migrations/20260308140000_add_playoffs.go @@ -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 + }, + ) +} diff --git a/internal/db/playoff.go b/internal/db/playoff.go new file mode 100644 index 0000000..01a2bdd --- /dev/null +++ b/internal/db/playoff.go @@ -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 +} diff --git a/internal/db/playoff_generation.go b/internal/db/playoff_generation.go new file mode 100644 index 0000000..176cb55 --- /dev/null +++ b/internal/db/playoff_generation.go @@ -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 +} diff --git a/internal/db/setup.go b/internal/db/setup.go index 7d7cb68..a335dbc 100644 --- a/internal/db/setup.go +++ b/internal/db/setup.go @@ -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 diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 3184498..57c827f 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -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%); } @@ -339,9 +336,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 +369,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); } @@ -493,6 +484,9 @@ .h-screen { height: 100vh; } + .max-h-40 { + max-height: calc(var(--spacing) * 40); + } .max-h-60 { max-height: calc(var(--spacing) * 60); } @@ -559,6 +553,9 @@ .w-20 { width: calc(var(--spacing) * 20); } + .w-24 { + width: calc(var(--spacing) * 24); + } .w-26 { width: calc(var(--spacing) * 26); } @@ -634,22 +631,12 @@ .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); @@ -758,9 +745,6 @@ .justify-end { justify-content: flex-end; } - .gap-0 { - gap: calc(var(--spacing) * 0); - } .gap-0\.5 { gap: calc(var(--spacing) * 0.5); } @@ -992,9 +976,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 +1069,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 +1099,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 +1246,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 +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)); 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,); @@ -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 { @media (hover: hover) { @@ -2685,11 +2672,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 +2774,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; diff --git a/internal/handlers/season_league_finals.go b/internal/handlers/season_league_finals.go index 58f665c..5288d1e 100644 --- a/internal/handlers/season_league_finals.go +++ b/internal/handlers/season_league_finals.go @@ -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 func SeasonLeagueFinalsPage( s *hws.Server, conn *db.DB, @@ -21,11 +31,13 @@ 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 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 +45,266 @@ 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") + } + 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), s, r, w) } 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_ 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 +} diff --git a/internal/permissions/constants.go b/internal/permissions/constants.go index cbbc646..eb666d5 100644 --- a/internal/permissions/constants.go +++ b/internal/permissions/constants.go @@ -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" diff --git a/internal/server/routes.go b/internal/server/routes.go index b7afe09..3b39c2d 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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, diff --git a/internal/view/seasonsview/finals_setup_form.templ b/internal/view/seasonsview/finals_setup_form.templ new file mode 100644 index 0000000..a830970 --- /dev/null +++ b/internal/view/seasonsview/finals_setup_form.templ @@ -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) + } + }} +
+
+ +
+

+ + Begin Finals Setup +

+

+ Configure playoff format and dates for { league.Name } +

+
+
+ +
+
+ @datepicker.DatePicker( + "regular_season_end_date", + "regular_season_end_date", + "Regular Season End Date", + "DD/MM/YYYY", + true, + "", + ) +

Games after this date will be forfeited

+
+
+ @datepicker.DatePicker( + "finals_start_date", + "finals_start_date", + "Finals Start Date", + "DD/MM/YYYY", + true, + "", + ) +

First playoff matches begin on this date

+
+
+ +
+ +
+ @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), + ) +
+
+ +
+ +
+ + + + + + +
+
+ + if len(unplayedFixtures) > 0 { +
+
+ + + +
+

+ { fmt.Sprintf("%d unplayed fixture(s) found", len(unplayedFixtures)) } +

+

+ These fixtures will be recorded as a mutual forfeit when you begin finals. + This action cannot be undone. +

+
+ for _, fixture := range unplayedFixtures { +
+ GW{ fmt.Sprint(*fixture.GameWeek) } + { fixture.HomeTeam.Name } + vs + { fixture.AwayTeam.Name } +
+ } +
+
+
+
+ } + + if len(leaderboard) > 0 { +
+ +
+ + + + + + + + + + + for _, entry := range leaderboard { + @standingsPreviewRow(entry, season, league) + } + +
#TeamGPPTS
+
+
+ } + +
+ + +
+
+
+
+} + +templ formatOption(value, label, description string, recommended bool, teamCount int) { + +} + +templ boRoundDropdown(name, label, description string) { +
+
+ { label } +

{ description }

+
+ +
+} + +templ standingsPreviewRow(entry *db.LeaderboardEntry, season *db.Season, league *db.League) { + + + { fmt.Sprint(entry.Position) } + + + @links.TeamLinkInSeason(entry.Team, season, league) + + + { fmt.Sprint(entry.Record.Played) } + + + { fmt.Sprint(entry.Record.Points) } + + +} diff --git a/internal/view/seasonsview/playoff_bracket.templ b/internal/view/seasonsview/playoff_bracket.templ new file mode 100644 index 0000000..5125487 --- /dev/null +++ b/internal/view/seasonsview/playoff_bracket.templ @@ -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) { +
+ +
+
+

+ + Finals Bracket +

+

+ { formatLabel(bracket.Format) } +

+
+
+ @playoffStatusBadge(bracket.Status) +
+
+ +
+ @bracketRounds(season, league, bracket) +
+
+} + +// 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 { +
+

+ { formatRoundName(roundName) } +

+
+ for _, s := range series { + @seriesCard(season, league, s) + } +
+
+ } + } +} + +templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) { +
+ +
+
+ { series.Label } + @seriesFormatBadge(series.MatchesToWin) +
+ @seriesStatusBadge(series.Status) +
+ +
+ @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) +
+ + if series.MatchesToWin > 1 { +
+ { fmt.Sprint(series.Team1Wins) } - { fmt.Sprint(series.Team2Wins) } +
+ } +
+} + +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 + }} +
+
+ if seed != nil { + + { fmt.Sprint(*seed) } + + } else { + - + } + if isTBD { + TBD + } else { + @links.TeamLinkInSeason(team, season, league) + if isWinner { + + } + } +
+ if matchesToWin > 1 { + + { fmt.Sprint(wins) } + + } +
+} + +templ playoffStatusBadge(status db.PlayoffStatus) { + switch status { + case db.PlayoffStatusUpcoming: + + Upcoming + + case db.PlayoffStatusInProgress: + + In Progress + + case db.PlayoffStatusCompleted: + + Completed + + } +} + +templ seriesFormatBadge(matchesToWin int) { + {{ + label := fmt.Sprintf("BO%d", matchesToWin*2-1) + }} + + { label } + +} + +templ seriesStatusBadge(status db.SeriesStatus) { + switch status { + case db.SeriesStatusPending: + + Pending + + case db.SeriesStatusInProgress: + + Live + + case db.SeriesStatusCompleted: + + Complete + + case db.SeriesStatusBye: + + Bye + + } +} diff --git a/internal/view/seasonsview/playoff_helpers.go b/internal/view/seasonsview/playoff_helpers.go new file mode 100644 index 0000000..3dc7db9 --- /dev/null +++ b/internal/view/seasonsview/playoff_helpers.go @@ -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) + } +} diff --git a/internal/view/seasonsview/season_league_finals.templ b/internal/view/seasonsview/season_league_finals.templ index 2311ef4..102f6a4 100644 --- a/internal/view/seasonsview/season_league_finals.templ +++ b/internal/view/seasonsview/season_league_finals.templ @@ -1,15 +1,59 @@ 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) { @SeasonLeagueLayout("finals", season, league) { - @SeasonLeagueFinals() + @SeasonLeagueFinals(season, league, bracket) } } -templ SeasonLeagueFinals() { -
-

Coming Soon...

+templ SeasonLeagueFinals(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { + {{ + status := season.GetStatus() + permCache := contexts.Permissions(ctx) + canManagePlayoffs := permCache != nil && permCache.HasPermission(permissions.PlayoffsManage) + }} +
+ if bracket != nil { + @PlayoffBracketView(season, league, bracket) + } else if status == db.StatusInProgress || status == db.StatusUpcoming { + @finalsRegularSeasonInProgress(season, league, canManagePlayoffs) + } else { + @finalsNotConfigured() + } +
+} + +templ finalsRegularSeasonInProgress(season *db.Season, league *db.League, canManagePlayoffs bool) { +
+
+ + + +
+

Regular Season in Progress

+

+ Finals will be available once the regular season is complete. +

+ if canManagePlayoffs { + + } +
+} + +templ finalsNotConfigured() { +
+

No finals configured for this league.

} diff --git a/justfile b/justfile index a6d9943..e1505d8 100644 --- a/justfile +++ b/justfile @@ -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" <