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. // 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, ) 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 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) if err != nil { if db.IsBadRequest(err) { throw.NotFound(s, w, r, r.URL.Path) return false, nil } 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(season, league, bracket), s, r, w) } else { 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 }