finals generation added
This commit is contained in:
@@ -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_<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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user