diff --git a/internal/db/playoff_results.go b/internal/db/playoff_results.go new file mode 100644 index 0000000..6e47b69 --- /dev/null +++ b/internal/db/playoff_results.go @@ -0,0 +1,355 @@ +package db + +import ( + "context" + "time" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// playoffFixtureRound generates a unique negative round number for a playoff game fixture. +// Format: -(seriesID * 100 + matchNumber) to avoid collision with regular season rounds. +func playoffFixtureRound(seriesID, matchNumber int) int { + return -(seriesID*100 + matchNumber) +} + +// CreatePlayoffGameFixture creates a Fixture record for a playoff game. +// The fixture is linked to the series via a PlayoffMatch record. +// team1 is "home", team2 is "away" in fixture terms. +func CreatePlayoffGameFixture( + ctx context.Context, + tx bun.Tx, + series *PlayoffSeries, + matchNumber int, + audit *AuditMeta, +) (*Fixture, *PlayoffMatch, error) { + if series == nil || series.Bracket == nil { + return nil, nil, errors.New("series and bracket cannot be nil") + } + if series.Team1ID == nil || series.Team2ID == nil { + return nil, nil, BadRequest("both teams must be assigned to create a game fixture") + } + + round := playoffFixtureRound(series.ID, matchNumber) + + fixture := &Fixture{ + SeasonID: series.Bracket.SeasonID, + LeagueID: series.Bracket.LeagueID, + HomeTeamID: *series.Team1ID, + AwayTeamID: *series.Team2ID, + Round: round, + CreatedAt: time.Now().Unix(), + } + + err := Insert(tx, fixture).WithAudit(audit, &AuditInfo{ + Action: "playoff_fixture.create", + ResourceType: "fixture", + ResourceID: nil, + Details: map[string]any{ + "series_id": series.ID, + "match_number": matchNumber, + "home_team_id": *series.Team1ID, + "away_team_id": *series.Team2ID, + }, + }).Exec(ctx) + if err != nil { + return nil, nil, errors.Wrap(err, "Insert fixture") + } + + // Create or update PlayoffMatch record + match := new(PlayoffMatch) + err = tx.NewSelect(). + Model(match). + Where("pm.series_id = ?", series.ID). + Where("pm.match_number = ?", matchNumber). + Scan(ctx) + + if err != nil && err.Error() != "sql: no rows in result set" { + return nil, nil, errors.Wrap(err, "tx.NewSelect playoff_match") + } + + if match.ID > 0 { + // Update existing match with fixture ID + match.FixtureID = &fixture.ID + match.HomeTeamID = series.Team1ID + match.AwayTeamID = series.Team2ID + match.Status = "pending" + err = UpdateByID(tx, match.ID, match). + Column("fixture_id", "home_team_id", "away_team_id", "status"). + Exec(ctx) + if err != nil { + return nil, nil, errors.Wrap(err, "UpdateByID playoff_match") + } + } else { + // Create new match + match = &PlayoffMatch{ + SeriesID: series.ID, + MatchNumber: matchNumber, + HomeTeamID: series.Team1ID, + AwayTeamID: series.Team2ID, + FixtureID: &fixture.ID, + Status: "pending", + } + err = Insert(tx, match).Exec(ctx) + if err != nil { + return nil, nil, errors.Wrap(err, "Insert playoff_match") + } + } + + // Load fixture relations + fixture.Season = series.Bracket.Season + fixture.League = series.Bracket.League + fixture.HomeTeam = series.Team1 + fixture.AwayTeam = series.Team2 + + return fixture, match, nil +} + +// FinalizeSeriesResults finalizes all pending game results for a series, +// updates series wins/status, and advances teams as needed. +// Returns the number of games finalized. +func FinalizeSeriesResults( + ctx context.Context, + tx bun.Tx, + seriesID int, + audit *AuditMeta, +) (int, error) { + series, err := GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return 0, errors.Wrap(err, "GetPlayoffSeriesByID") + } + if series == nil { + return 0, BadRequest("series not found") + } + + if series.Status == SeriesStatusCompleted { + return 0, BadRequest("series is already completed") + } + + // Collect all matches with fixtures that have pending results + gamesFinalized := 0 + team1Wins := 0 + team2Wins := 0 + + for _, match := range series.Matches { + if match.FixtureID == nil { + continue + } + + result, err := GetFixtureResult(ctx, tx, *match.FixtureID) + if err != nil { + return 0, errors.Wrap(err, "GetFixtureResult") + } + if result == nil { + continue + } + + // Finalize the fixture result if pending + if !result.Finalized { + err = FinalizeFixtureResult(ctx, tx, *match.FixtureID, audit) + if err != nil { + return 0, errors.Wrap(err, "FinalizeFixtureResult") + } + gamesFinalized++ + } + + // Update match status + now := time.Now().Unix() + match.Status = "completed" + err = UpdateByID(tx, match.ID, match). + Column("status"). + Exec(ctx) + if err != nil { + return 0, errors.Wrap(err, "UpdateByID playoff_match") + } + _ = now + + // Count wins: team1 = home, team2 = away in fixture terms + if result.Winner == "home" { + team1Wins++ + } else { + team2Wins++ + } + } + + if gamesFinalized == 0 { + return 0, BadRequest("no pending results to finalize") + } + + // Update series wins + series.Team1Wins = team1Wins + series.Team2Wins = team2Wins + + // Determine if series is decided + if team1Wins >= series.MatchesToWin || team2Wins >= series.MatchesToWin { + series.Status = SeriesStatusCompleted + + if team1Wins >= series.MatchesToWin { + series.WinnerTeamID = series.Team1ID + series.LoserTeamID = series.Team2ID + } else { + series.WinnerTeamID = series.Team2ID + series.LoserTeamID = series.Team1ID + } + + err = UpdateByID(tx, series.ID, series). + Column("team1_wins", "team2_wins", "status", "winner_team_id", "loser_team_id"). + WithAudit(audit, &AuditInfo{ + Action: "playoff_series.complete", + ResourceType: "playoff_series", + ResourceID: series.ID, + Details: map[string]any{ + "team1_wins": team1Wins, + "team2_wins": team2Wins, + "winner_team_id": series.WinnerTeamID, + "loser_team_id": series.LoserTeamID, + }, + }).Exec(ctx) + if err != nil { + return 0, errors.Wrap(err, "UpdateByID series complete") + } + + // Advance winner to next series + if series.WinnerNextID != nil && series.WinnerNextSlot != nil { + err = advanceTeamToSeries(ctx, tx, *series.WinnerNextID, *series.WinnerNextSlot, *series.WinnerTeamID) + if err != nil { + return 0, errors.Wrap(err, "advanceTeamToSeries winner") + } + } + + // Advance loser to next series (e.g. third place, lower bracket) + if series.LoserNextID != nil && series.LoserNextSlot != nil { + err = advanceTeamToSeries(ctx, tx, *series.LoserNextID, *series.LoserNextSlot, *series.LoserTeamID) + if err != nil { + return 0, errors.Wrap(err, "advanceTeamToSeries loser") + } + } + } else { + // Series still in progress + series.Status = SeriesStatusInProgress + err = UpdateByID(tx, series.ID, series). + Column("team1_wins", "team2_wins", "status"). + Exec(ctx) + if err != nil { + return 0, errors.Wrap(err, "UpdateByID series in_progress") + } + } + + return gamesFinalized, nil +} + +// advanceTeamToSeries places a team into the specified slot of the target series. +func advanceTeamToSeries(ctx context.Context, tx bun.Tx, targetSeriesID int, slot string, teamID int) error { + switch slot { + case "team1": + _, err := tx.NewUpdate(). + Model((*PlayoffSeries)(nil)). + Set("team1_id = ?", teamID). + Where("id = ?", targetSeriesID). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "update team1_id") + } + case "team2": + _, err := tx.NewUpdate(). + Model((*PlayoffSeries)(nil)). + Set("team2_id = ?", teamID). + Where("id = ?", targetSeriesID). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "update team2_id") + } + default: + return BadRequest("invalid slot: " + slot) + } + return nil +} + +// DeleteSeriesResults deletes all pending (non-finalized) fixture results +// and their associated fixtures for a series. +func DeleteSeriesResults( + ctx context.Context, + tx bun.Tx, + seriesID int, + audit *AuditMeta, +) error { + series, err := GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return errors.Wrap(err, "GetPlayoffSeriesByID") + } + if series == nil { + return BadRequest("series not found") + } + + for _, match := range series.Matches { + if match.FixtureID == nil { + continue + } + + result, err := GetFixtureResult(ctx, tx, *match.FixtureID) + if err != nil { + return errors.Wrap(err, "GetFixtureResult") + } + if result == nil { + continue + } + if result.Finalized { + return BadRequest("cannot discard finalized results") + } + + // Delete the result (CASCADE deletes player stats) + err = DeleteFixtureResult(ctx, tx, *match.FixtureID, audit) + if err != nil { + return errors.Wrap(err, "DeleteFixtureResult") + } + + // Delete the fixture + err = DeleteByID[Fixture](tx, *match.FixtureID). + WithAudit(audit, &AuditInfo{ + Action: "playoff_fixture.delete", + ResourceType: "fixture", + ResourceID: *match.FixtureID, + }).Delete(ctx) + if err != nil { + return errors.Wrap(err, "DeleteByID fixture") + } + + // Clear fixture ID from match + match.FixtureID = nil + match.Status = "pending" + err = UpdateByID(tx, match.ID, match). + Column("fixture_id", "status"). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID playoff_match") + } + } + + return nil +} + +// HasPendingSeriesResults checks if a series has any pending (non-finalized) results. +func HasPendingSeriesResults(ctx context.Context, tx bun.Tx, seriesID int) (bool, error) { + series, err := GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "GetPlayoffSeriesByID") + } + if series == nil { + return false, nil + } + + for _, match := range series.Matches { + if match.FixtureID == nil { + continue + } + result, err := GetPendingFixtureResult(ctx, tx, *match.FixtureID) + if err != nil { + return false, errors.Wrap(err, "GetPendingFixtureResult") + } + if result != nil { + return true, nil + } + } + return false, nil +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index bfc81c6..025f3c2 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -327,6 +327,9 @@ max-width: 96rem; } } + .mx-1 { + margin-inline: calc(var(--spacing) * 1); + } .mx-auto { margin-inline: auto; } @@ -469,6 +472,9 @@ .h-9 { height: calc(var(--spacing) * 9); } + .h-10 { + height: calc(var(--spacing) * 10); + } .h-12 { height: calc(var(--spacing) * 12); } @@ -673,6 +679,9 @@ --tw-scale-z: 100%; scale: var(--tw-scale-x) var(--tw-scale-y); } + .rotate-180 { + rotate: 180deg; + } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } @@ -757,6 +766,9 @@ .justify-end { justify-content: flex-end; } + .gap-0 { + gap: calc(var(--spacing) * 0); + } .gap-0\.5 { gap: calc(var(--spacing) * 0.5); } @@ -1581,6 +1593,11 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } .duration-150 { --tw-duration: 150ms; transition-duration: 150ms; @@ -2419,6 +2436,16 @@ gap: calc(var(--spacing) * 12); } } + .lg\:divide-x { + @media (width >= 64rem) { + :where(& > :not(:last-child)) { + --tw-divide-x-reverse: 0; + border-inline-style: var(--tw-border-style); + border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); + border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); + } + } + } .lg\:px-8 { @media (width >= 64rem) { padding-inline: calc(var(--spacing) * 8); diff --git a/internal/handlers/series_result.go b/internal/handlers/series_result.go new file mode 100644 index 0000000..2f1faf1 --- /dev/null +++ b/internal/handlers/series_result.go @@ -0,0 +1,502 @@ +package handlers + +import ( + "context" + "io" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "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/view/seasonsview" + "git.haelnorr.com/h/oslstats/pkg/slapshotapi" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// SeriesUploadResultPage renders the upload form for series match logs +func SeriesUploadResultPage( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series ID", err) + return + } + + var series *db.PlayoffSeries + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + + // Check for existing pending results + hasPending, err := db.HasPendingSeriesResults(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.HasPendingSeriesResults") + } + if hasPending { + throw.BadRequest(s, w, r, "Pending results already exist for this series. Discard them first to re-upload.", nil) + return false, nil + } + + return true, nil + }); !ok { + return + } + + renderSafely(seasonsview.SeriesUploadResultPage(series), s, r, w) + }) +} + +// SeriesUploadResults handles POST /series/{series_id}/results/upload +// Parses match logs for all games, creates fixtures + results. +func SeriesUploadResults( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series ID", err) + return + } + + // Parse multipart form + err = r.ParseMultipartForm(maxUploadSize * 5) // up to 5 games worth + if err != nil { + notify.Warn(s, w, r, "Upload Failed", "Could not parse uploaded files.", nil) + return + } + + gameCountStr := r.FormValue("game_count") + gameCount, err := strconv.Atoi(gameCountStr) + if err != nil || gameCount < 1 { + notify.Warn(s, w, r, "Invalid Input", "Please select a valid number of games.", nil) + return + } + + // Parse all game logs + type gameLogs struct { + Logs [3]*slapshotapi.MatchLog + } + allGameLogs := make([]*gameLogs, gameCount) + + for g := 1; g <= gameCount; g++ { + gl := &gameLogs{} + for p := 1; p <= 3; p++ { + fieldName := "game_" + strconv.Itoa(g) + "_period_" + strconv.Itoa(p) + file, _, err := r.FormFile(fieldName) + if err != nil { + notify.Warn(s, w, r, "Missing File", + "All 3 period files are required for Game "+strconv.Itoa(g)+". Missing period "+strconv.Itoa(p)+".", nil) + return + } + defer func() { _ = file.Close() }() + + data, err := io.ReadAll(file) + if err != nil { + notify.Warn(s, w, r, "Read Error", "Could not read file: "+fieldName, nil) + return + } + + log, err := slapshotapi.ParseMatchLog(data) + if err != nil { + notify.Warn(s, w, r, "Parse Error", + "Could not parse Game "+strconv.Itoa(g)+" Period "+strconv.Itoa(p)+": "+err.Error(), nil) + return + } + gl.Logs[p-1] = log + } + allGameLogs[g-1] = gl + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + respond.NotFound(w, errors.New("series not found")) + return false, nil + } + + // Validate game count + maxGames := series.MatchesToWin*2 - 1 + if gameCount < series.MatchesToWin || gameCount > maxGames { + notify.Warn(s, w, r, "Invalid Game Count", + "Game count must be between "+strconv.Itoa(series.MatchesToWin)+" and "+strconv.Itoa(maxGames)+".", nil) + return false, nil + } + + // Check for existing pending results + hasPending, err := db.HasPendingSeriesResults(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.HasPendingSeriesResults") + } + if hasPending { + notify.Warn(s, w, r, "Results Exist", "Pending results already exist. Discard them first.", nil) + return false, nil + } + + audit := db.NewAuditFromRequest(r) + user := db.CurrentUser(ctx) + + // Process each game + team1Wins := 0 + team2Wins := 0 + + for g := 0; g < gameCount; g++ { + gl := allGameLogs[g] + logs := []*slapshotapi.MatchLog{gl.Logs[0], gl.Logs[1], gl.Logs[2]} + matchNumber := g + 1 + + // Check if series is already decided + if team1Wins >= series.MatchesToWin || team2Wins >= series.MatchesToWin { + notify.Warn(s, w, r, "Too Many Games", + "The series was already decided before Game "+strconv.Itoa(matchNumber)+". Reduce the game count.", nil) + return false, nil + } + + // Detect tampering + tamperingDetected, tamperingReason, err := slapshotapi.DetectTampering(logs) + if err != nil { + notify.Warn(s, w, r, "Validation Error", + "Game "+strconv.Itoa(matchNumber)+" tampering check failed: "+err.Error(), nil) + return false, nil + } + + // Create fixture for this game + fixture, _, err := db.CreatePlayoffGameFixture(ctx, tx, series, matchNumber, audit) + if err != nil { + return false, errors.Wrap(err, "db.CreatePlayoffGameFixture") + } + + // Collect game_user_ids + gameUserIDSet := map[string]bool{} + for _, log := range logs { + for _, p := range log.Players { + gameUserIDSet[p.GameUserID] = true + } + } + gameUserIDs := make([]string, 0, len(gameUserIDSet)) + for id := range gameUserIDSet { + gameUserIDs = append(gameUserIDs, id) + } + + // Map players + playerLookup, err := MapGameUserIDsToPlayers(ctx, tx, gameUserIDs, fixture.SeasonID, fixture.LeagueID) + if err != nil { + return false, errors.Wrap(err, "MapGameUserIDsToPlayers") + } + + // Determine orientation + allPlayers := logs[2].Players + fixtureHomeIsLogsHome, _, err := DetermineTeamOrientation(ctx, tx, fixture, allPlayers, playerLookup) + if err != nil { + notify.Warn(s, w, r, "Orientation Error", + "Game "+strconv.Itoa(matchNumber)+": Could not determine team orientation: "+err.Error(), nil) + return false, nil + } + + // Build result + finalLog := logs[2] + winner := finalLog.Winner + homeScore := finalLog.Score.Home + awayScore := finalLog.Score.Away + if !fixtureHomeIsLogsHome { + switch winner { + case "home": + winner = "away" + case "away": + winner = "home" + } + homeScore, awayScore = awayScore, homeScore + } + + periodsEnabled := finalLog.PeriodsEnabled == "True" + customMercyRule, _ := strconv.Atoi(finalLog.CustomMercyRule) + matchLength, _ := strconv.Atoi(finalLog.MatchLength) + + var tamperingReasonPtr *string + if tamperingDetected { + tamperingReasonPtr = &tamperingReason + } + + result := &db.FixtureResult{ + FixtureID: fixture.ID, + Winner: winner, + HomeScore: homeScore, + AwayScore: awayScore, + MatchType: finalLog.Type, + Arena: finalLog.Arena, + EndReason: finalLog.EndReason, + PeriodsEnabled: periodsEnabled, + CustomMercyRule: customMercyRule, + MatchLength: matchLength, + UploadedByUserID: user.ID, + Finalized: false, + TamperingDetected: tamperingDetected, + TamperingReason: tamperingReasonPtr, + } + + // Build player stats + playerStats := []*db.FixtureResultPlayerStats{} + for periodIdx, log := range logs { + periodNum := periodIdx + 1 + for _, p := range log.Players { + team := p.Team + if !fixtureHomeIsLogsHome { + if team == "home" { + team = "away" + } else { + team = "home" + } + } + + var playerID *int + var teamID *int + if lookup, ok := playerLookup[p.GameUserID]; ok && lookup.Found { + playerID = &lookup.Player.ID + if !lookup.Unmapped { + teamID = &lookup.TeamID + } + } + + stat := &db.FixtureResultPlayerStats{ + PeriodNum: periodNum, + PlayerID: playerID, + PlayerGameUserID: p.GameUserID, + PlayerUsername: p.Username, + TeamID: teamID, + Team: team, + Goals: FloatToIntPtr(p.Stats.Goals), + Assists: FloatToIntPtr(p.Stats.Assists), + PrimaryAssists: FloatToIntPtr(p.Stats.PrimaryAssists), + SecondaryAssists: FloatToIntPtr(p.Stats.SecondaryAssists), + Saves: FloatToIntPtr(p.Stats.Saves), + Blocks: FloatToIntPtr(p.Stats.Blocks), + Shots: FloatToIntPtr(p.Stats.Shots), + Turnovers: FloatToIntPtr(p.Stats.Turnovers), + Takeaways: FloatToIntPtr(p.Stats.Takeaways), + Passes: FloatToIntPtr(p.Stats.Passes), + PossessionTimeSec: FloatToIntPtr(p.Stats.PossessionTime), + FaceoffsWon: FloatToIntPtr(p.Stats.FaceoffsWon), + FaceoffsLost: FloatToIntPtr(p.Stats.FaceoffsLost), + PostHits: FloatToIntPtr(p.Stats.PostHits), + OvertimeGoals: FloatToIntPtr(p.Stats.OvertimeGoals), + GameWinningGoals: FloatToIntPtr(p.Stats.GameWinningGoals), + Score: FloatToIntPtr(p.Stats.Score), + ContributedGoals: FloatToIntPtr(p.Stats.ContributedGoals), + ConcededGoals: FloatToIntPtr(p.Stats.ConcededGoals), + GamesPlayed: FloatToIntPtr(p.Stats.GamesPlayed), + Wins: FloatToIntPtr(p.Stats.Wins), + Losses: FloatToIntPtr(p.Stats.Losses), + OvertimeWins: FloatToIntPtr(p.Stats.OvertimeWins), + OvertimeLosses: FloatToIntPtr(p.Stats.OvertimeLosses), + Ties: FloatToIntPtr(p.Stats.Ties), + Shutouts: FloatToIntPtr(p.Stats.Shutouts), + ShutoutsAgainst: FloatToIntPtr(p.Stats.ShutsAgainst), + HasMercyRuled: FloatToIntPtr(p.Stats.HasMercyRuled), + WasMercyRuled: FloatToIntPtr(p.Stats.WasMercyRuled), + PeriodsPlayed: FloatToIntPtr(p.Stats.PeriodsPlayed), + } + playerStats = append(playerStats, stat) + } + } + + // Mark free agents + for _, ps := range playerStats { + if ps.PlayerID == nil { + continue + } + isFA, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, *ps.PlayerID) + if err != nil { + return false, errors.Wrap(err, "db.IsFreeAgentRegistered") + } + if isFA { + ps.IsFreeAgent = true + } + } + + // Insert result + _, err = db.InsertFixtureResult(ctx, tx, result, playerStats, audit) + if err != nil { + return false, errors.Wrap(err, "db.InsertFixtureResult") + } + + // Track wins: home = team1, away = team2 + if winner == "home" { + team1Wins++ + } else { + team2Wins++ + } + } + + // Validate that the series result is valid + if team1Wins < series.MatchesToWin && team2Wins < series.MatchesToWin { + notify.Warn(s, w, r, "Incomplete Series", + "Neither team has enough wins to decide the series. More games are needed.", nil) + return false, nil + } + + return true, nil + }); !ok { + return + } + + respond.HXRedirect(w, "/series/%d/results/review", seriesID) + }) +} + +// SeriesReviewResults handles GET /series/{series_id}/results/review +func SeriesReviewResults( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series ID", err) + return + } + + var series *db.PlayoffSeries + var gameResults []*seasonsview.SeriesGameResult + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + + // Build game results from matches + for _, match := range series.Matches { + if match.FixtureID == nil { + continue + } + + result, err := db.GetPendingFixtureResult(ctx, tx, *match.FixtureID) + if err != nil { + return false, errors.Wrap(err, "db.GetPendingFixtureResult") + } + if result == nil { + continue + } + + gr := &seasonsview.SeriesGameResult{ + GameNumber: match.MatchNumber, + Result: result, + } + + // Build unmapped players and FA warnings + for _, ps := range result.PlayerStats { + if ps.PeriodNum != 3 { + continue + } + if ps.PlayerID == nil { + gr.UnmappedPlayers = append(gr.UnmappedPlayers, + ps.PlayerGameUserID+" ("+ps.PlayerUsername+")") + } else if ps.IsFreeAgent { + gr.FreeAgentWarnings = append(gr.FreeAgentWarnings, seasonsview.FreeAgentWarning{ + Name: ps.PlayerUsername, + Reason: "free agent in playoff match", + }) + } + } + + gameResults = append(gameResults, gr) + } + + if len(gameResults) == 0 { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + + return true, nil + }); !ok { + return + } + + renderSafely(seasonsview.SeriesReviewResultPage(series, gameResults), s, r, w) + }) +} + +// SeriesFinalizeResults handles POST /series/{series_id}/results/finalize +func SeriesFinalizeResults( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series ID", err) + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + _, err := db.FinalizeSeriesResults(ctx, tx, seriesID, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Finalize", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.FinalizeSeriesResults") + } + return true, nil + }); !ok { + return + } + + notify.SuccessWithDelay(s, w, r, "Series Finalized", "All game results have been finalized and the series is complete.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} + +// SeriesDiscardResults handles POST /series/{series_id}/results/discard +func SeriesDiscardResults( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series ID", err) + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + err := db.DeleteSeriesResults(ctx, tx, seriesID, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Discard", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.DeleteSeriesResults") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Results Discarded", "All uploaded results have been discarded. You can upload new logs.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 3bb0aae..e76dcb6 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -391,6 +391,32 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.CancelSeriesScheduleHandler(s, conn)), }, + // Series result management routes + { + Path: "/series/{series_id}/results/upload", + Method: hws.MethodGET, + Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesUploadResultPage(s, conn)), + }, + { + Path: "/series/{series_id}/results/upload", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesUploadResults(s, conn)), + }, + { + Path: "/series/{series_id}/results/review", + Method: hws.MethodGET, + Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesReviewResults(s, conn)), + }, + { + Path: "/series/{series_id}/results/finalize", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesFinalizeResults(s, conn)), + }, + { + Path: "/series/{series_id}/results/discard", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesDiscardResults(s, conn)), + }, } playerRoutes := []hws.Route{ diff --git a/internal/view/seasonsview/series_detail.templ b/internal/view/seasonsview/series_detail.templ index fb4a911..e517d19 100644 --- a/internal/view/seasonsview/series_detail.templ +++ b/internal/view/seasonsview/series_detail.templ @@ -216,9 +216,8 @@ templ SeriesDetailOverviewContent( {{ permCache := contexts.Permissions(ctx) canManage := permCache.HasPermission(permissions.PlayoffsManage) - _ = canManage }} - @seriesOverviewTab(series, currentSchedule, rosters, canSchedule, userTeamID) + @seriesOverviewTab(series, currentSchedule, rosters, canSchedule, canManage, userTeamID) } templ SeriesDetailPreviewContent( @@ -257,8 +256,15 @@ templ seriesOverviewTab( currentSchedule *db.PlayoffSeriesSchedule, rosters map[string][]*db.PlayerWithPlayStatus, canSchedule bool, + canManage bool, userTeamID int, ) { + {{ + isCompleted := series.Status == db.SeriesStatusCompleted + isBye := series.Status == db.SeriesStatusBye + bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil + showUploadPrompt := canManage && !isCompleted && !isBye && bothTeamsAssigned + }}
@@ -270,6 +276,11 @@ templ seriesOverviewTab(
+ + if showUploadPrompt { + @seriesUploadPrompt(series) + } + if len(series.Matches) > 0 { @seriesMatchList(series) @@ -290,6 +301,44 @@ templ seriesOverviewTab( } +templ seriesUploadPrompt(series *db.PlayoffSeries) { + {{ + // Check if there are pending results waiting for review + hasPendingMatches := false + for _, match := range series.Matches { + if match.FixtureID != nil && match.Status == "pending" { + hasPendingMatches = true + break + } + } + }} +
+ if hasPendingMatches { +
๐Ÿ“‹
+

Results Pending Review

+

Uploaded results are waiting to be reviewed and finalized.

+ + Review Results + + } else { +
๐Ÿ“‹
+

No Results Uploaded

+

Upload match log files to record the series results.

+ + Upload Match Logs + + } +
+} + templ seriesScoreDisplay(series *db.PlayoffSeries) { {{ isCompleted := series.Status == db.SeriesStatusCompleted diff --git a/internal/view/seasonsview/series_review_result.templ b/internal/view/seasonsview/series_review_result.templ new file mode 100644 index 0000000..fde65c1 --- /dev/null +++ b/internal/view/seasonsview/series_review_result.templ @@ -0,0 +1,374 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" + +// SeriesGameResult holds the parsed result for a single game in the series review +type SeriesGameResult struct { + GameNumber int + Result *db.FixtureResult + UnmappedPlayers []string + FreeAgentWarnings []FreeAgentWarning +} + +templ SeriesReviewResultPage( + series *db.PlayoffSeries, + gameResults []*SeriesGameResult, +) { + {{ + backURL := fmt.Sprintf("/series/%d", series.ID) + team1Name := seriesTeamName(series.Team1) + team2Name := seriesTeamName(series.Team2) + + // Calculate series score from the results + team1Wins := 0 + team2Wins := 0 + for _, gr := range gameResults { + if gr.Result != nil { + if gr.Result.Winner == "home" { + team1Wins++ + } else { + team2Wins++ + } + } + } + }} + @baseview.Layout(fmt.Sprintf("Review Series Result โ€” %s vs %s", team1Name, team2Name)) { +
+ +
+
+
+
+

Review Series Result

+

+ { team1Name } vs { team2Name } + { series.Label } +

+
+ + Back to Series + +
+
+
+ +
+
+

Series Result

+
+
+
+
+ if series.Team1 != nil && series.Team1.Color != "" { +
+ } +

{ team1Name }

+

team2Wins), templ.KV("text-text", team1Wins <= team2Wins) }> + { fmt.Sprint(team1Wins) } +

+ if team1Wins > team2Wins { + Winner + } +
+ โ€“ +
+ if series.Team2 != nil && series.Team2.Color != "" { +
+ } +

{ team2Name }

+

team1Wins), templ.KV("text-text", team2Wins <= team1Wins) }> + { fmt.Sprint(team2Wins) } +

+ if team2Wins > team1Wins { + Winner + } +
+
+

+ { fmt.Sprint(len(gameResults)) } game(s) played +

+
+
+ +
+ for _, gr := range gameResults { + @seriesReviewGameCard(series, gr) + } +
+ +
+
+

Actions

+
+
+
+ + +
+
+
+
+ } +} + +templ seriesReviewGameCard(series *db.PlayoffSeries, gr *SeriesGameResult) { + {{ + team1Name := seriesTeamName(series.Team1) + team2Name := seriesTeamName(series.Team2) + result := gr.Result + homeWon := result.Winner == "home" + winnerName := team2Name + if homeWon { + winnerName = team1Name + } + hasWarnings := result.TamperingDetected || len(gr.UnmappedPlayers) > 0 || len(gr.FreeAgentWarnings) > 0 + }} +
+ +
+
+

Game { fmt.Sprint(gr.GameNumber) }

+ if hasWarnings { + โš  + } +
+
+ + { team1Name } + { fmt.Sprint(result.HomeScore) } + - + { fmt.Sprint(result.AwayScore) } + { team2Name } + + + { winnerName } + + + + + +
+
+ +
+ + if hasWarnings { +
+ if result.TamperingDetected && result.TamperingReason != nil { +
+
+ โš  Inconsistent Data Detected +
+

{ *result.TamperingReason }

+

+ This does not block finalization but should be reviewed carefully. +

+
+ } + if len(gr.FreeAgentWarnings) > 0 { +
+
+ โš  Free Agent Issues +
+
    + for _, fa := range gr.FreeAgentWarnings { +
  • + { fa.Name } + โ€” { fa.Reason } +
  • + } +
+
+ } + if len(gr.UnmappedPlayers) > 0 { +
+
+ โš  Unmapped Players +
+

+ Could not be matched to registered players. +

+
    + for _, p := range gr.UnmappedPlayers { +
  • { p }
  • + } +
+
+ } +
+ } + +
+
+
+

{ team1Name }

+

+ { fmt.Sprint(result.HomeScore) } +

+
+
โ€”
+
+

{ team2Name }

+

+ { fmt.Sprint(result.AwayScore) } +

+
+
+
+ if result.Arena != "" { + { result.Arena } + } + if result.EndReason != "" { + { result.EndReason } + } + + Winner: { winnerName } + +
+
+ +
+ if series.Team1 != nil { + @seriesReviewTeamStats(series.Team1, result, "home", series.Bracket.Season, series.Bracket.League) + } + if series.Team2 != nil { + @seriesReviewTeamStats(series.Team2, result, "away", series.Bracket.Season, series.Bracket.League) + } +
+
+
+} + +templ seriesReviewTeamStats(team *db.Team, result *db.FixtureResult, side string, season *db.Season, league *db.League) { + {{ + type playerStat struct { + Username string + PlayerID *int + Stats *db.FixtureResultPlayerStats + } + finalStats := []*playerStat{} + seen := map[string]bool{} + for _, ps := range result.PlayerStats { + if ps.Team == side && ps.PeriodNum == 3 { + if !seen[ps.PlayerGameUserID] { + seen[ps.PlayerGameUserID] = true + finalStats = append(finalStats, &playerStat{ + Username: ps.PlayerUsername, + PlayerID: ps.PlayerID, + Stats: ps, + }) + } + } + } + }} +
+
+ if team.Color != "" { + + } +

+ if side == "home" { + Team 1 โ€” + } else { + Team 2 โ€” + } + @links.TeamNameLinkInSeason(team, season, league) +

+
+
+ + + + + + + + + + + + + + + + for _, ps := range finalStats { + + + + + + + + + + + + } + if len(finalStats) == 0 { + + + + } + +
PlayerPPGASVSHBLPASC
+ + if ps.PlayerID != nil { + @links.PlayerLinkFromStats(*ps.PlayerID, ps.Username) + } else { + { ps.Username } + ? + } + if ps.Stats.IsFreeAgent { + + FA + + } + + { intPtrStr(ps.Stats.PeriodsPlayed) }{ intPtrStr(ps.Stats.Goals) }{ intPtrStr(ps.Stats.Assists) }{ intPtrStr(ps.Stats.Saves) }{ intPtrStr(ps.Stats.Shots) }{ intPtrStr(ps.Stats.Blocks) }{ intPtrStr(ps.Stats.Passes) }{ intPtrStr(ps.Stats.Score) }
+ No player stats recorded +
+
+
+} diff --git a/internal/view/seasonsview/series_upload_result.templ b/internal/view/seasonsview/series_upload_result.templ new file mode 100644 index 0000000..e00e14e --- /dev/null +++ b/internal/view/seasonsview/series_upload_result.templ @@ -0,0 +1,133 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "fmt" + +templ SeriesUploadResultPage(series *db.PlayoffSeries) { + {{ + backURL := fmt.Sprintf("/series/%d", series.ID) + team1Name := seriesTeamName(series.Team1) + team2Name := seriesTeamName(series.Team2) + boLabel := fmt.Sprintf("BO%d", series.MatchesToWin*2-1) + maxGames := series.MatchesToWin*2 - 1 + minGames := series.MatchesToWin + }} + @baseview.Layout(fmt.Sprintf("Upload Series Result โ€” %s vs %s", team1Name, team2Name)) { +
+ +
+
+
+
+

Upload Series Results

+

+ { team1Name } vs { team2Name } + + { series.Label } ยท { boLabel } + +

+
+ + Cancel + +
+
+
+ +
+
+

Match Log Files

+
+
+

+ Upload the 3 period match log JSON files for each game in the series. + Select the number of games that were actually played. +

+
+ +
+ + +

+ First team to { fmt.Sprint(series.MatchesToWin) } wins takes the series + ({ fmt.Sprint(minGames) }-{ fmt.Sprint(maxGames) } games possible) +

+
+ + for g := 1; g <= maxGames; g++ { +
= %d", g) } + x-cloak + class="border border-surface1 rounded-lg overflow-hidden" + > +
+

Game { fmt.Sprint(g) }

+
+
+ for p := 1; p <= 3; p++ { +
+ + +
+ } +
+
+ } + +
+ +
+
+
+
+
+ } +}