335 lines
9.7 KiB
Go
335 lines
9.7 KiB
Go
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 + finals stats
|
|
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
|
|
var topGoals []*db.LeagueTopGoalScorer
|
|
var topAssists []*db.LeagueTopAssister
|
|
var topSaves []*db.LeagueTopSaver
|
|
var allStats []*db.LeaguePlayerStats
|
|
|
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
|
var err error
|
|
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
|
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")
|
|
}
|
|
|
|
// Load playoff stats if bracket exists
|
|
if bracket != nil {
|
|
topGoals, err = db.GetPlayoffTopGoalScorers(ctx, tx, season.ID, league.ID)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "db.GetPlayoffTopGoalScorers")
|
|
}
|
|
topAssists, err = db.GetPlayoffTopAssisters(ctx, tx, season.ID, league.ID)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "db.GetPlayoffTopAssisters")
|
|
}
|
|
topSaves, err = db.GetPlayoffTopSavers(ctx, tx, season.ID, league.ID)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "db.GetPlayoffTopSavers")
|
|
}
|
|
allStats, err = db.GetPlayoffPlayerStats(ctx, tx, season.ID, league.ID)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "db.GetPlayoffPlayerStats")
|
|
}
|
|
}
|
|
|
|
return true, nil
|
|
}); !ok {
|
|
return
|
|
}
|
|
|
|
if r.Method == "GET" {
|
|
renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league, bracket, topGoals, topAssists, topSaves, allStats), s, r, w)
|
|
} else {
|
|
renderSafely(seasonsview.SeasonLeagueFinals(season, league, bracket, topGoals, topAssists, topSaves, allStats), s, r, w)
|
|
}
|
|
})
|
|
}
|
|
|
|
// SeasonLeagueFinalsSetupForm renders the finals setup form via HTMX.
|
|
// Shows date pickers, format selection, unplayed fixture warnings, and standings preview.
|
|
func SeasonLeagueFinalsSetupForm(
|
|
s *hws.Server,
|
|
conn *db.DB,
|
|
) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seasonStr := r.PathValue("season_short_name")
|
|
leagueStr := r.PathValue("league_short_name")
|
|
|
|
var season *db.Season
|
|
var league *db.League
|
|
var leaderboard []*db.LeaderboardEntry
|
|
var unplayedFixtures []*db.Fixture
|
|
|
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
|
var err error
|
|
var teams []*db.Team
|
|
season, league, teams, err = db.GetSeasonLeagueWithTeams(ctx, tx, seasonStr, leagueStr)
|
|
if err != nil {
|
|
if db.IsBadRequest(err) {
|
|
throw.NotFound(s, w, r, r.URL.Path)
|
|
return false, nil
|
|
}
|
|
return false, errors.Wrap(err, "db.GetSeasonLeagueWithTeams")
|
|
}
|
|
|
|
// Get allocated fixtures and results for leaderboard
|
|
fixtures, err := db.GetAllocatedFixtures(ctx, tx, season.ID, league.ID)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "db.GetAllocatedFixtures")
|
|
}
|
|
|
|
fixtureIDs := make([]int, len(fixtures))
|
|
for i, f := range fixtures {
|
|
fixtureIDs[i] = f.ID
|
|
}
|
|
|
|
resultMap, err := db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures")
|
|
}
|
|
|
|
leaderboard = db.ComputeLeaderboard(teams, fixtures, resultMap)
|
|
|
|
// Get unplayed fixtures
|
|
unplayedFixtures, err = db.GetUnplayedFixtures(ctx, tx, season.ID, league.ID)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "db.GetUnplayedFixtures")
|
|
}
|
|
|
|
return true, nil
|
|
}); !ok {
|
|
return
|
|
}
|
|
|
|
renderSafely(seasonsview.FinalsSetupForm(
|
|
season, league, leaderboard, unplayedFixtures,
|
|
), s, r, w)
|
|
})
|
|
}
|
|
|
|
// SeasonLeagueFinalsSetupSubmit processes the finals setup form.
|
|
// It validates inputs, auto-forfeits unplayed fixtures, updates season dates,
|
|
// and generates the playoff bracket.
|
|
func SeasonLeagueFinalsSetupSubmit(
|
|
s *hws.Server,
|
|
conn *db.DB,
|
|
) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seasonStr := r.PathValue("season_short_name")
|
|
leagueStr := r.PathValue("league_short_name")
|
|
|
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
format := timefmt.NewBuilder().
|
|
DayNumeric2().Slash().
|
|
MonthNumeric2().Slash().
|
|
Year4().Build()
|
|
|
|
endDate := getter.Time("regular_season_end_date", format).Required().Value
|
|
finalsStartDate := getter.Time("finals_start_date", format).Required().Value
|
|
playoffFormat := getter.String("format").TrimSpace().Required().
|
|
AllowedValues([]string{
|
|
string(db.PlayoffFormat5to6),
|
|
string(db.PlayoffFormat7to9),
|
|
string(db.PlayoffFormat10to15),
|
|
}).Value
|
|
|
|
if !getter.ValidateAndNotify(s, w, r) {
|
|
return
|
|
}
|
|
|
|
// Validate finals start is after end date
|
|
if !finalsStartDate.After(endDate) && !finalsStartDate.Equal(endDate) {
|
|
notify.Warn(s, w, r, "Invalid Dates",
|
|
"Finals start date must be on or after the regular season end date.", nil)
|
|
return
|
|
}
|
|
|
|
// Parse per-round BO configuration from form fields
|
|
roundFormats := parseRoundFormats(r, db.PlayoffFormat(playoffFormat))
|
|
|
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
|
// Get season, league, teams
|
|
var teams []*db.Team
|
|
season, league, teams, err := db.GetSeasonLeagueWithTeams(ctx, tx, seasonStr, leagueStr)
|
|
if err != nil {
|
|
if db.IsBadRequest(err) {
|
|
respond.NotFound(w, err)
|
|
return false, nil
|
|
}
|
|
return false, errors.Wrap(err, "db.GetSeasonLeagueWithTeams")
|
|
}
|
|
|
|
// Check no bracket already exists
|
|
existing, err := db.GetPlayoffBracket(ctx, tx, season.ID, league.ID)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "db.GetPlayoffBracket")
|
|
}
|
|
if existing != nil {
|
|
notify.Warn(s, w, r, "Already Exists",
|
|
"A playoff bracket already exists for this league.", nil)
|
|
return false, nil
|
|
}
|
|
|
|
user := db.CurrentUser(ctx)
|
|
audit := db.NewAuditFromRequest(r)
|
|
|
|
// Auto-forfeit unplayed fixtures
|
|
forfeitCount, err := db.AutoForfeitUnplayedFixtures(
|
|
ctx, tx, season.ID, league.ID, user.ID, audit)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "db.AutoForfeitUnplayedFixtures")
|
|
}
|
|
|
|
// Update season dates
|
|
err = season.Update(ctx, tx,
|
|
season.SlapVersion,
|
|
season.StartDate,
|
|
endDate,
|
|
finalsStartDate,
|
|
season.FinalsEndDate.Time,
|
|
audit,
|
|
)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "season.Update")
|
|
}
|
|
|
|
// Compute final leaderboard (after forfeits)
|
|
fixtures, err := db.GetAllocatedFixtures(ctx, tx, season.ID, league.ID)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "db.GetAllocatedFixtures")
|
|
}
|
|
|
|
fixtureIDs := make([]int, len(fixtures))
|
|
for i, f := range fixtures {
|
|
fixtureIDs[i] = f.ID
|
|
}
|
|
|
|
resultMap, err := db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures")
|
|
}
|
|
|
|
leaderboard := db.ComputeLeaderboard(teams, fixtures, resultMap)
|
|
|
|
// Generate the bracket
|
|
_, err = db.GeneratePlayoffBracket(
|
|
ctx, tx,
|
|
season.ID, league.ID,
|
|
db.PlayoffFormat(playoffFormat),
|
|
leaderboard,
|
|
roundFormats,
|
|
audit,
|
|
)
|
|
if err != nil {
|
|
if db.IsBadRequest(err) {
|
|
notify.Warn(s, w, r, "Cannot Create Bracket", err.Error(), nil)
|
|
return false, nil
|
|
}
|
|
return false, errors.Wrap(err, "db.GeneratePlayoffBracket")
|
|
}
|
|
|
|
_ = forfeitCount
|
|
return true, nil
|
|
}); !ok {
|
|
return
|
|
}
|
|
|
|
url := fmt.Sprintf("/seasons/%s/leagues/%s/finals", seasonStr, leagueStr)
|
|
respond.HXRedirect(w, "%s", url)
|
|
notify.SuccessWithDelay(s, w, r, "Finals Created",
|
|
"Playoff bracket has been generated successfully.", nil)
|
|
})
|
|
}
|
|
|
|
// parseRoundFormats reads bo_<round> form fields and returns a map of round name
|
|
// to matches_to_win value (1 = BO1, 2 = BO3, 3 = BO5).
|
|
// Form fields are named like "bo_grand_final", "bo_semi_final", etc.
|
|
func parseRoundFormats(r *http.Request, format db.PlayoffFormat) map[string]int {
|
|
roundFormats := make(map[string]int)
|
|
|
|
var rounds []string
|
|
switch format {
|
|
case db.PlayoffFormat5to6:
|
|
rounds = []string{
|
|
"upper_bracket", "lower_bracket",
|
|
"upper_final", "lower_final",
|
|
"grand_final",
|
|
}
|
|
case db.PlayoffFormat7to9:
|
|
rounds = []string{
|
|
"quarter_final", "semi_final",
|
|
"third_place", "grand_final",
|
|
}
|
|
case db.PlayoffFormat10to15:
|
|
rounds = []string{
|
|
"qualifying_final", "elimination_final",
|
|
"semi_final", "preliminary_final",
|
|
"third_place", "grand_final",
|
|
}
|
|
}
|
|
|
|
for _, round := range rounds {
|
|
val := r.FormValue("bo_" + round)
|
|
if val == "" {
|
|
continue
|
|
}
|
|
mtw, err := strconv.Atoi(val)
|
|
if err != nil || mtw < 1 || mtw > 3 {
|
|
continue // Invalid values default to BO1 in getMatchesToWin
|
|
}
|
|
roundFormats[round] = mtw
|
|
}
|
|
|
|
return roundFormats
|
|
}
|