Files
oslstats/internal/handlers/season_league_finals.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
}