From 1b99403e10224e7714c5df34b89cfbef32365094 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Thu, 5 Mar 2026 22:22:32 +1100 Subject: [PATCH] forfeits added --- internal/db/fixture_result.go | 135 +++++++ .../20260305140000_add_forfeit_support.go | 89 +++++ internal/embedfs/web/css/output.css | 34 ++ internal/handlers/forfeit.go | 95 +++++ internal/server/routes.go | 6 + .../view/seasonsview/fixture_detail.templ | 376 +++++++++++++++--- .../seasonsview/season_league_fixtures.templ | 44 +- .../season_league_team_detail.templ | 50 ++- 8 files changed, 746 insertions(+), 83 deletions(-) create mode 100644 internal/db/migrations/20260305140000_add_forfeit_support.go create mode 100644 internal/handlers/forfeit.go diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go index be4bbbb..390d69e 100644 --- a/internal/db/fixture_result.go +++ b/internal/db/fixture_result.go @@ -31,6 +31,12 @@ type FixtureResult struct { TamperingDetected bool `bun:",default:false"` TamperingReason *string + // Forfeit-related fields + IsForfeit bool `bun:"is_forfeit,default:false"` + ForfeitType *string `bun:"forfeit_type"` // "mutual" or "outright" + ForfeitTeam *string `bun:"forfeit_team"` // "home" or "away" (nil for mutual) + ForfeitReason *string `bun:"forfeit_reason"` // User-provided reason + Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"` UploadedBy *User `bun:"rel:belongs-to,join:uploaded_by_user_id=id"` PlayerStats []*FixtureResultPlayerStats `bun:"rel:has-many,join:id=fixture_result_id"` @@ -95,6 +101,106 @@ type PlayerWithPlayStatus struct { Stats *FixtureResultPlayerStats // Period 3 (final cumulative) stats, nil if no result } +// Forfeit type constants +const ( + ForfeitTypeMutual = "mutual" + ForfeitTypeOutright = "outright" +) + +// CreateForfeitResult creates a finalized forfeit result for a fixture. +// For outright forfeits, forfeitTeam specifies which team ("home" or "away") forfeited. +// For mutual forfeits, forfeitTeam should be empty. +func CreateForfeitResult( + ctx context.Context, + tx bun.Tx, + fixture *Fixture, + forfeitType string, + forfeitTeam string, + reason string, + userID int, + audit *AuditMeta, +) (*FixtureResult, error) { + if fixture == nil { + return nil, errors.New("fixture cannot be nil") + } + + // Validate forfeit type + if forfeitType != ForfeitTypeMutual && forfeitType != ForfeitTypeOutright { + return nil, BadRequest("invalid forfeit type: must be 'mutual' or 'outright'") + } + + // Validate forfeit team for outright forfeits + if forfeitType == ForfeitTypeOutright { + if forfeitTeam != "home" && forfeitTeam != "away" { + return nil, BadRequest("outright forfeit requires a team: must be 'home' or 'away'") + } + } + + // Determine winner and scores based on forfeit type + var winner string + var homeScore, awayScore int + var endReason string + var forfeitTeamPtr *string + + switch forfeitType { + case ForfeitTypeMutual: + // Mutual forfeit: both teams get an OT loss, no winner + // Use "draw" as winner to signal mutual loss + winner = "draw" + homeScore = 0 + awayScore = 0 + endReason = "Forfeit" + case ForfeitTypeOutright: + // Outright forfeit: forfeiting team loses, opponent wins + forfeitTeamPtr = &forfeitTeam + if forfeitTeam == "home" { + winner = "away" + } else { + winner = "home" + } + homeScore = 0 + awayScore = 0 + endReason = "Forfeit" + } + + var reasonPtr *string + if reason != "" { + reasonPtr = &reason + } + + result := &FixtureResult{ + FixtureID: fixture.ID, + Winner: winner, + HomeScore: homeScore, + AwayScore: awayScore, + EndReason: endReason, + UploadedByUserID: userID, + Finalized: true, // Forfeits are immediately finalized + IsForfeit: true, + ForfeitType: &forfeitType, + ForfeitTeam: forfeitTeamPtr, + ForfeitReason: reasonPtr, + CreatedAt: time.Now().Unix(), + } + + err := Insert(tx, result).WithAudit(audit, &AuditInfo{ + Action: "fixture_results.forfeit", + ResourceType: "fixture_result", + ResourceID: nil, + Details: map[string]any{ + "fixture_id": fixture.ID, + "forfeit_type": forfeitType, + "forfeit_team": forfeitTeam, + "reason": reason, + }, + }).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "Insert forfeit result") + } + + return result, nil +} + // InsertFixtureResult stores a new match result with all player stats in a single transaction. func InsertFixtureResult( ctx context.Context, @@ -352,6 +458,7 @@ const ( // ComputeTeamRecord calculates W-OTW-OTL-L, GF/GA, and points from fixtures and results. // Points: Win=3, OT Win=2, OT Loss=1, Loss=0. +// Forfeits: Outright = Win(3)/Loss(0), Mutual = OT Loss(1) for both teams. func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*FixtureResult) *TeamRecord { rec := &TeamRecord{} for _, f := range fixtures { @@ -361,6 +468,34 @@ func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*Fixtu } rec.Played++ isHome := f.HomeTeamID == teamID + + // Handle forfeits separately + if res.IsForfeit { + // Forfeits have 0-0 score, no goal impact + if res.ForfeitType != nil && *res.ForfeitType == ForfeitTypeMutual { + // Mutual forfeit: both teams get OT loss (1 point) + rec.OvertimeLosses++ + rec.Points += PointsOvertimeLoss + } else if res.ForfeitType != nil && *res.ForfeitType == ForfeitTypeOutright { + // Outright forfeit: check if this team forfeited + thisSide := "away" + if isHome { + thisSide = "home" + } + if res.ForfeitTeam != nil && *res.ForfeitTeam == thisSide { + // This team forfeited - loss + rec.Losses++ + rec.Points += PointsLoss + } else { + // Opponent forfeited - win + rec.Wins++ + rec.Points += PointsWin + } + } + continue + } + + // Normal match handling if isHome { rec.GoalsFor += res.HomeScore rec.GoalsAgainst += res.AwayScore diff --git a/internal/db/migrations/20260305140000_add_forfeit_support.go b/internal/db/migrations/20260305140000_add_forfeit_support.go new file mode 100644 index 0000000..f89f454 --- /dev/null +++ b/internal/db/migrations/20260305140000_add_forfeit_support.go @@ -0,0 +1,89 @@ +package migrations + +import ( + "context" + + "git.haelnorr.com/h/oslstats/internal/db" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister( + // UP migration + func(ctx context.Context, conn *bun.DB) error { + // Add is_forfeit column + _, err := conn.NewAddColumn(). + Model((*db.FixtureResult)(nil)). + ColumnExpr("is_forfeit BOOLEAN NOT NULL DEFAULT false"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Add forfeit_type column + _, err = conn.NewAddColumn(). + Model((*db.FixtureResult)(nil)). + ColumnExpr("forfeit_type VARCHAR"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Add forfeit_team column + _, err = conn.NewAddColumn(). + Model((*db.FixtureResult)(nil)). + ColumnExpr("forfeit_team VARCHAR"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Add forfeit_reason column + _, err = conn.NewAddColumn(). + Model((*db.FixtureResult)(nil)). + ColumnExpr("forfeit_reason VARCHAR"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + return nil + }, + // DOWN migration + func(ctx context.Context, conn *bun.DB) error { + _, err := conn.NewDropColumn(). + Model((*db.FixtureResult)(nil)). + ColumnExpr("forfeit_reason"). + Exec(ctx) + if err != nil { + return err + } + + _, err = conn.NewDropColumn(). + Model((*db.FixtureResult)(nil)). + ColumnExpr("forfeit_team"). + Exec(ctx) + if err != nil { + return err + } + + _, err = conn.NewDropColumn(). + Model((*db.FixtureResult)(nil)). + ColumnExpr("forfeit_type"). + Exec(ctx) + if err != nil { + return err + } + + _, err = conn.NewDropColumn(). + Model((*db.FixtureResult)(nil)). + ColumnExpr("is_forfeit"). + Exec(ctx) + return err + }, + ) +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index bf6a9fc..be3d0b6 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -906,6 +906,12 @@ .border-overlay0 { border-color: var(--overlay0); } + .border-peach\/50 { + border-color: var(--peach); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--peach) 50%, transparent); + } + } .border-red { border-color: var(--red); } @@ -915,6 +921,12 @@ border-color: color-mix(in oklab, var(--red) 30%, transparent); } } + .border-red\/50 { + border-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--red) 50%, transparent); + } + } .border-surface0 { border-color: var(--surface0); } @@ -1008,6 +1020,12 @@ .bg-peach { background-color: var(--peach); } + .bg-peach\/5 { + background-color: var(--peach); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--peach) 5%, transparent); + } + } .bg-peach\/20 { background-color: var(--peach); @supports (color: color-mix(in lab, red, red)) { @@ -1017,6 +1035,12 @@ .bg-red { background-color: var(--red); } + .bg-red\/5 { + background-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--red) 5%, transparent); + } + } .bg-red\/10 { background-color: var(--red); @supports (color: color-mix(in lab, red, red)) { @@ -1848,6 +1872,16 @@ --tw-ring-color: var(--mauve); } } + .focus\:ring-peach { + &:focus { + --tw-ring-color: var(--peach); + } + } + .focus\:ring-red { + &:focus { + --tw-ring-color: var(--red); + } + } .focus\:outline-none { &:focus { --tw-outline-style: none; diff --git a/internal/handlers/forfeit.go b/internal/handlers/forfeit.go new file mode 100644 index 0000000..3d85e49 --- /dev/null +++ b/internal/handlers/forfeit.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "context" + "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/validation" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// ForfeitFixture handles POST /fixtures/{fixture_id}/forfeit +// Creates a finalized forfeit result for the fixture. Requires fixtures.manage permission. +func ForfeitFixture( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid fixture ID", err) + return + } + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + forfeitType := getter.String("forfeit_type").TrimSpace().Required().Value + forfeitTeam := getter.String("forfeit_team").TrimSpace().Value + forfeitReason := getter.String("forfeit_reason").TrimSpace().Value + if !getter.ValidateAndNotify(s, w, r) { + return + } + + // Validate forfeit type + if forfeitType != db.ForfeitTypeMutual && forfeitType != db.ForfeitTypeOutright { + notify.Warn(s, w, r, "Invalid Forfeit Type", "Forfeit type must be 'mutual' or 'outright'.", nil) + return + } + + // Validate forfeit team for outright forfeits + if forfeitType == db.ForfeitTypeOutright { + if forfeitTeam != "home" && forfeitTeam != "away" { + notify.Warn(s, w, r, "Missing Team", "An outright forfeit requires specifying which team forfeited.", nil) + return + } + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + fixture, err := db.GetFixture(ctx, tx, fixtureID) + if err != nil { + if db.IsBadRequest(err) { + respond.NotFound(w, errors.Wrap(err, "db.GetFixture")) + return false, nil + } + return false, errors.Wrap(err, "db.GetFixture") + } + + // Check if a result already exists + existing, err := db.GetFixtureResult(ctx, tx, fixtureID) + if err != nil { + return false, errors.Wrap(err, "db.GetFixtureResult") + } + if existing != nil { + notify.Warn(s, w, r, "Result Exists", + "A result already exists for this fixture. Discard it first to record a forfeit.", nil) + return false, nil + } + + user := db.CurrentUser(ctx) + + _, err = db.CreateForfeitResult(ctx, tx, fixture, forfeitType, forfeitTeam, forfeitReason, user.ID, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Forfeit", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.CreateForfeitResult") + } + return true, nil + }); !ok { + return + } + + notify.SuccessWithDelay(s, w, r, "Forfeit Recorded", "The forfeit has been recorded and finalized.", nil) + respond.HXRedirect(w, "/fixtures/%d", fixtureID) + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index de3489b..38fda14 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -287,6 +287,12 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.DiscardMatchResult(s, conn)), }, + // Forfeit route + { + Path: "/fixtures/{fixture_id}/forfeit", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.ForfeitFixture(s, conn)), + }, } teamRoutes := []hws.Route{ diff --git a/internal/view/seasonsview/fixture_detail.templ b/internal/view/seasonsview/fixture_detail.templ index a9147f8..cc33e2e 100644 --- a/internal/view/seasonsview/fixture_detail.templ +++ b/internal/view/seasonsview/fixture_detail.templ @@ -220,11 +220,28 @@ templ fixtureResultDisplay(fixture *db.Fixture, result *db.FixtureResult) { isOT := strings.EqualFold(result.EndReason, "Overtime") homeWon := result.Winner == "home" awayWon := result.Winner == "away" + isForfeit := result.IsForfeit + isMutualForfeit := isForfeit && result.ForfeitType != nil && *result.ForfeitType == "mutual" + isOutrightForfeit := isForfeit && result.ForfeitType != nil && *result.ForfeitType == "outright" + _ = isMutualForfeit + forfeitTeamName := "" + if isOutrightForfeit && result.ForfeitTeam != nil { + if *result.ForfeitTeam == "home" { + forfeitTeamName = fixture.HomeTeam.Name + } else { + forfeitTeamName = fixture.AwayTeam.Name + } + } }}

Match Result

+ if isForfeit { + + Forfeited + + } if result.Finalized { Finalized @@ -263,55 +280,154 @@ templ fixtureResultDisplay(fixture *db.Fixture, result *db.FixtureResult) {

{ *result.TamperingReason }

} - -
-
- if homeWon { - 🏆 + if isForfeit { + +
+ if isMutualForfeit { +
+
+ if fixture.HomeTeam.Color != "" { + + { fixture.HomeTeam.ShortName } + + } else { + + { fixture.HomeTeam.ShortName } + + } +
+
+ + MUTUAL FORFEIT + +
+
+ if fixture.AwayTeam.Color != "" { + + { fixture.AwayTeam.ShortName } + + } else { + + { fixture.AwayTeam.ShortName } + + } +
+
+

Both teams receive an overtime loss

+ } else if isOutrightForfeit { +
+
+ if homeWon { + 🏆 + } + if fixture.HomeTeam.Color != "" { + + { fixture.HomeTeam.ShortName } + + } else { + + { fixture.HomeTeam.ShortName } + + } +
+
+ + FORFEIT + +
+
+ if fixture.AwayTeam.Color != "" { + + { fixture.AwayTeam.ShortName } + + } else { + + { fixture.AwayTeam.ShortName } + + } + if awayWon { + 🏆 + } +
+
+

+ { forfeitTeamName } forfeited the match +

} - if fixture.HomeTeam.Color != "" { - - { fixture.HomeTeam.ShortName } - - } else { - - { fixture.HomeTeam.ShortName } - - } - { fmt.Sprint(result.HomeScore) } -
-
- - if isOT { - - OT - + if result.ForfeitReason != nil && *result.ForfeitReason != "" { +
+

Reason

+

{ *result.ForfeitReason }

+
}
-
- { fmt.Sprint(result.AwayScore) } - if fixture.AwayTeam.Color != "" { - - { fixture.AwayTeam.ShortName } - - } else { - - { fixture.AwayTeam.ShortName } - - } - if awayWon { - 🏆 - } + } else { + +
+
+ if homeWon { + 🏆 + } + if fixture.HomeTeam.Color != "" { + + { fixture.HomeTeam.ShortName } + + } else { + + { fixture.HomeTeam.ShortName } + + } + { fmt.Sprint(result.HomeScore) } +
+
+ + if isOT { + + OT + + } +
+
+ { fmt.Sprint(result.AwayScore) } + if fixture.AwayTeam.Color != "" { + + { fixture.AwayTeam.ShortName } + + } else { + + { fixture.AwayTeam.ShortName } + + } + if awayWon { + 🏆 + } +
-
+ }
} @@ -321,13 +437,169 @@ templ fixtureUploadPrompt(fixture *db.Fixture) {
📋

No Result Uploaded

Upload match log files to record the result of this fixture.

- - Upload Match Logs - +
+ + Upload Match Logs + + +
+
+ @forfeitModal(fixture) +} + +templ forfeitModal(fixture *db.Fixture) { + } diff --git a/internal/view/seasonsview/season_league_fixtures.templ b/internal/view/seasonsview/season_league_fixtures.templ index 2c4f4a9..a264619 100644 --- a/internal/view/seasonsview/season_league_fixtures.templ +++ b/internal/view/seasonsview/season_league_fixtures.templ @@ -159,21 +159,35 @@ templ fixtureListItem(fixture *db.Fixture, sched *db.FixtureSchedule, hasSchedul
if hasResult { - - if res.Winner == "home" { - { fmt.Sprint(res.HomeScore) } - - { fmt.Sprint(res.AwayScore) } - } else if res.Winner == "away" { - { fmt.Sprint(res.HomeScore) } - - { fmt.Sprint(res.AwayScore) } - } else { - { fmt.Sprint(res.HomeScore) } - - { fmt.Sprint(res.AwayScore) } - } - + if res.IsForfeit { + + if res.ForfeitType != nil && *res.ForfeitType == "mutual" { + + Mutual Forfeit + + } else { + + Forfeit + + } + + } else { + + if res.Winner == "home" { + { fmt.Sprint(res.HomeScore) } + + { fmt.Sprint(res.AwayScore) } + } else if res.Winner == "away" { + { fmt.Sprint(res.HomeScore) } + + { fmt.Sprint(res.AwayScore) } + } else { + { fmt.Sprint(res.HomeScore) } + + { fmt.Sprint(res.AwayScore) } + } + + } } else if hasSchedule && sched.ScheduledTime != nil { @localtime(sched.ScheduledTime, "short") diff --git a/internal/view/seasonsview/season_league_team_detail.templ b/internal/view/seasonsview/season_league_team_detail.templ index f40dc3e..ad1dedf 100644 --- a/internal/view/seasonsview/season_league_team_detail.templ +++ b/internal/view/seasonsview/season_league_team_detail.templ @@ -520,13 +520,17 @@ templ teamResultRow(team *db.Team, fixture *db.Fixture, resultMap map[int]*db.Fi won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away") lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home") _ = lost + isForfeit := res.IsForfeit + isMutualForfeit := isForfeit && res.ForfeitType != nil && *res.ForfeitType == "mutual" }}
- if won { + if isMutualForfeit { + FF + } else if won { W } else if lost { L @@ -550,21 +554,35 @@ templ teamResultRow(team *db.Team, fixture *db.Fixture, resultMap map[int]*db.Fi { opponent }
- - if res.Winner == "home" { - { fmt.Sprint(res.HomeScore) } - - { fmt.Sprint(res.AwayScore) } - } else if res.Winner == "away" { - { fmt.Sprint(res.HomeScore) } - - { fmt.Sprint(res.AwayScore) } - } else { - { fmt.Sprint(res.HomeScore) } - - { fmt.Sprint(res.AwayScore) } - } - + if isForfeit { + + if isMutualForfeit { + + Mutual Forfeit + + } else { + + Forfeit + + } + + } else { + + if res.Winner == "home" { + { fmt.Sprint(res.HomeScore) } + + { fmt.Sprint(res.AwayScore) } + } else if res.Winner == "away" { + { fmt.Sprint(res.HomeScore) } + + { fmt.Sprint(res.AwayScore) } + } else { + { fmt.Sprint(res.HomeScore) } + + { fmt.Sprint(res.AwayScore) } + } + + }
}