From 2b5c43cf613098764ccd85983b11c68a1e8c61f7 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Thu, 5 Mar 2026 18:56:48 +1100 Subject: [PATCH 01/33] playernames now added on creation --- internal/db/player.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/db/player.go b/internal/db/player.go index 27871cc..7577b2d 100644 --- a/internal/db/player.go +++ b/internal/db/player.go @@ -28,14 +28,15 @@ func (p *Player) DisplayName() string { // NewPlayer creates a new player in the database. If there is an existing user with the same // discordID, it will automatically link that user to the player -func NewPlayer(ctx context.Context, tx bun.Tx, discordID string, audit *AuditMeta) (*Player, error) { - player := &Player{DiscordID: discordID} +func NewPlayer(ctx context.Context, tx bun.Tx, name, discordID string, audit *AuditMeta) (*Player, error) { + player := &Player{DiscordID: discordID, Name: name} user, err := GetUserByDiscordID(ctx, tx, discordID) if err != nil && !IsBadRequest(err) { return nil, errors.Wrap(err, "GetUserByDiscordID") } if user != nil { player.UserID = &user.ID + player.Name = user.Username } err = Insert(tx, player). WithAudit(audit, nil).Exec(ctx) @@ -56,7 +57,7 @@ func (u *User) ConnectPlayer(ctx context.Context, tx bun.Tx, audit *AuditMeta) e return errors.Wrap(err, "GetByField") } // Player doesn't exist, create a new one - player, err = NewPlayer(ctx, tx, u.DiscordID, audit) + player, err = NewPlayer(ctx, tx, u.Username, u.DiscordID, audit) if err != nil { return errors.Wrap(err, "NewPlayer") } From 08344877c73ba43d9cd0ddca1b2703e5283bccf8 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Thu, 5 Mar 2026 22:22:32 +1100 Subject: [PATCH 02/33] 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) } + } + + }
} From e526f42ac3aaf68edb882ee0a4bd5c23a3dc02a0 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 18:23:37 +1100 Subject: [PATCH 03/33] added periods played --- internal/db/fixture_result.go | 22 ++++++++++--------- .../view/seasonsview/fixture_detail.templ | 4 +++- .../seasonsview/fixture_review_result.templ | 4 +++- .../season_league_team_detail.templ | 2 ++ 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go index 390d69e..002daf1 100644 --- a/internal/db/fixture_result.go +++ b/internal/db/fixture_result.go @@ -381,16 +381,17 @@ func GetFinalizedResultsForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs [ // AggregatedPlayerStats holds summed stats for a player across multiple fixtures. type AggregatedPlayerStats struct { - PlayerID int `bun:"player_id"` - PlayerName string `bun:"player_name"` - GamesPlayed int `bun:"games_played"` - Score int `bun:"total_score"` - Goals int `bun:"total_goals"` - Assists int `bun:"total_assists"` - Saves int `bun:"total_saves"` - Shots int `bun:"total_shots"` - Blocks int `bun:"total_blocks"` - Passes int `bun:"total_passes"` + PlayerID int `bun:"player_id"` + PlayerName string `bun:"player_name"` + GamesPlayed int `bun:"games_played"` + PeriodsPlayed int `bun:"total_periods_played"` + Score int `bun:"total_score"` + Goals int `bun:"total_goals"` + Assists int `bun:"total_assists"` + Saves int `bun:"total_saves"` + Shots int `bun:"total_shots"` + Blocks int `bun:"total_blocks"` + Passes int `bun:"total_passes"` } // GetAggregatedPlayerStatsForTeam returns aggregated period-3 stats for all mapped @@ -411,6 +412,7 @@ func GetAggregatedPlayerStatsForTeam( frps.player_id AS player_id, COALESCE(p.name, frps.player_username) AS player_name, COUNT(DISTINCT frps.fixture_result_id) AS games_played, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, COALESCE(SUM(frps.score), 0) AS total_score, COALESCE(SUM(frps.goals), 0) AS total_goals, COALESCE(SUM(frps.assists), 0) AS total_assists, diff --git a/internal/view/seasonsview/fixture_detail.templ b/internal/view/seasonsview/fixture_detail.templ index cc33e2e..c9d34cc 100644 --- a/internal/view/seasonsview/fixture_detail.templ +++ b/internal/view/seasonsview/fixture_detail.templ @@ -662,6 +662,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side Player + PP SC G A @@ -690,6 +691,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
if p.Stats != nil { + { intPtrStr(p.Stats.PeriodsPlayed) } { intPtrStr(p.Stats.Score) } { intPtrStr(p.Stats.Goals) } { intPtrStr(p.Stats.Assists) } @@ -698,7 +700,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side { intPtrStr(p.Stats.Blocks) } { intPtrStr(p.Stats.Passes) } } else { - โ€” + โ€” } } diff --git a/internal/view/seasonsview/fixture_review_result.templ b/internal/view/seasonsview/fixture_review_result.templ index 8ac5308..e01796b 100644 --- a/internal/view/seasonsview/fixture_review_result.templ +++ b/internal/view/seasonsview/fixture_review_result.templ @@ -205,6 +205,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) { Player + PP G A SV @@ -230,6 +231,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) { } + { intPtrStr(ps.Stats.PeriodsPlayed) } { intPtrStr(ps.Stats.Goals) } { intPtrStr(ps.Stats.Assists) } { intPtrStr(ps.Stats.Saves) } @@ -241,7 +243,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) { } if len(finalStats) == 0 { - + No player stats recorded diff --git a/internal/view/seasonsview/season_league_team_detail.templ b/internal/view/seasonsview/season_league_team_detail.templ index ad1dedf..0022edd 100644 --- a/internal/view/seasonsview/season_league_team_detail.templ +++ b/internal/view/seasonsview/season_league_team_detail.templ @@ -617,6 +617,7 @@ templ teamStatsSection(record *db.TeamRecord, playerStats []*db.AggregatedPlayer Player GP + PP SC G A @@ -631,6 +632,7 @@ templ teamStatsSection(record *db.TeamRecord, playerStats []*db.AggregatedPlayer { ps.PlayerName } { fmt.Sprint(ps.GamesPlayed) } + { fmt.Sprint(ps.PeriodsPlayed) } { fmt.Sprint(ps.Score) } { fmt.Sprint(ps.Goals) } { fmt.Sprint(ps.Assists) } From cade057e428b718cf5ac7b61b0fe70c7cca08593 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 18:29:23 +1100 Subject: [PATCH 04/33] added ot stats to team view --- internal/embedfs/web/css/output.css | 7 +++++-- .../view/seasonsview/season_league_team_detail.templ | 10 ++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index be3d0b6..e04cc37 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -672,6 +672,9 @@ .grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } .grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); } @@ -2174,9 +2177,9 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } - .lg\:grid-cols-6 { + .lg\:grid-cols-8 { @media (width >= 64rem) { - grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-template-columns: repeat(8, minmax(0, 1fr)); } } .lg\:items-end { diff --git a/internal/view/seasonsview/season_league_team_detail.templ b/internal/view/seasonsview/season_league_team_detail.templ index 0022edd..83a0b84 100644 --- a/internal/view/seasonsview/season_league_team_detail.templ +++ b/internal/view/seasonsview/season_league_team_detail.templ @@ -599,13 +599,15 @@ templ teamStatsSection(record *db.TeamRecord, playerStats []*db.AggregatedPlayer } else {
-
+
@statCell("Played", fmt.Sprint(record.Played), "") - @statCell("Record", fmt.Sprintf("%d-%d-%d", record.Wins, record.Losses, record.Draws), "") - @statCell("Wins", fmt.Sprint(record.Wins), "text-green") - @statCell("Losses", fmt.Sprint(record.Losses), "text-red") + @statCell("W", fmt.Sprint(record.Wins), "text-green") + @statCell("OTW", fmt.Sprint(record.OvertimeWins), "text-teal") + @statCell("OTL", fmt.Sprint(record.OvertimeLosses), "text-peach") + @statCell("L", fmt.Sprint(record.Losses), "text-red") @statCell("GF", fmt.Sprint(record.GoalsFor), "") @statCell("GA", fmt.Sprint(record.GoalsAgainst), "") + @statCell("PTS", fmt.Sprint(record.Points), "text-blue")
From 1634b27991fa6bed06bbbaf4ea0ca92e45a153c4 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 18:53:58 +1100 Subject: [PATCH 05/33] added team overview --- internal/db/team.go | 147 ++++++++++++++++++ internal/handlers/team_detail.go | 67 ++++++++ internal/server/routes.go | 5 + .../season_league_team_detail.templ | 23 ++- internal/view/teamsview/detail_page.templ | 81 ++++++++++ .../view/teamsview/detail_player_stats.templ | 127 +++++++++++++++ internal/view/teamsview/detail_seasons.templ | 86 ++++++++++ internal/view/teamsview/list_page.templ | 9 +- 8 files changed, 535 insertions(+), 10 deletions(-) create mode 100644 internal/handlers/team_detail.go create mode 100644 internal/view/teamsview/detail_page.templ create mode 100644 internal/view/teamsview/detail_player_stats.templ create mode 100644 internal/view/teamsview/detail_seasons.templ diff --git a/internal/db/team.go b/internal/db/team.go index 52174db..b3dde40 100644 --- a/internal/db/team.go +++ b/internal/db/team.go @@ -2,6 +2,7 @@ package db import ( "context" + "sort" "github.com/pkg/errors" "github.com/uptrace/bun" @@ -72,3 +73,149 @@ func (t *Team) InSeason(seasonID int) bool { } return false } + +// TeamSeasonInfo holds information about a team's participation in a specific season+league. +type TeamSeasonInfo struct { + Season *Season + League *League + Record *TeamRecord + TotalTeams int + Position int +} + +// GetTeamSeasonParticipation returns all season+league combos the team participated in, +// with computed records, positions, and total team counts. +func GetTeamSeasonParticipation( + ctx context.Context, + tx bun.Tx, + teamID int, +) ([]*TeamSeasonInfo, error) { + if teamID == 0 { + return nil, errors.New("teamID not provided") + } + + // Get all participations for this team + var participations []*TeamParticipation + err := tx.NewSelect(). + Model(&participations). + Where("team_id = ?", teamID). + Relation("Season", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Relation("Leagues") + }). + Relation("League"). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect participations") + } + + var results []*TeamSeasonInfo + + for _, p := range participations { + // Get all teams in this season+league for position calculation + var teams []*Team + err := tx.NewSelect(). + Model(&teams). + Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id"). + Where("tp.season_id = ? AND tp.league_id = ?", p.SeasonID, p.LeagueID). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect teams") + } + + // Get all fixtures for this season+league + fixtures, err := GetAllocatedFixtures(ctx, tx, p.SeasonID, p.LeagueID) + if err != nil { + return nil, errors.Wrap(err, "GetAllocatedFixtures") + } + + fixtureIDs := make([]int, len(fixtures)) + for i, f := range fixtures { + fixtureIDs[i] = f.ID + } + + resultMap, err := GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs) + if err != nil { + return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures") + } + + // Compute leaderboard to get position + leaderboard := ComputeLeaderboard(teams, fixtures, resultMap) + + var position int + var record *TeamRecord + for _, entry := range leaderboard { + if entry.Team.ID == teamID { + position = entry.Position + record = entry.Record + break + } + } + if record == nil { + record = &TeamRecord{} + } + + results = append(results, &TeamSeasonInfo{ + Season: p.Season, + League: p.League, + Record: record, + TotalTeams: len(teams), + Position: position, + }) + } + + // Sort by season start date descending (newest first) + sort.Slice(results, func(i, j int) bool { + return results[i].Season.StartDate.After(results[j].Season.StartDate) + }) + + return results, nil +} + +// TeamAllTimePlayerStats holds aggregated all-time stats for a player on a team. +type TeamAllTimePlayerStats struct { + PlayerID int `bun:"player_id"` + PlayerName string `bun:"player_name"` + SeasonsPlayed int `bun:"seasons_played"` + PeriodsPlayed int `bun:"total_periods_played"` + Goals int `bun:"total_goals"` + Assists int `bun:"total_assists"` + Saves int `bun:"total_saves"` +} + +// GetTeamAllTimePlayerStats returns aggregated all-time stats for all players +// who have ever played for a given team across all seasons. +func GetTeamAllTimePlayerStats( + ctx context.Context, + tx bun.Tx, + teamID int, +) ([]*TeamAllTimePlayerStats, error) { + if teamID == 0 { + return nil, errors.New("teamID not provided") + } + + var stats []*TeamAllTimePlayerStats + err := tx.NewRaw(` + SELECT + frps.player_id AS player_id, + COALESCE(p.name, frps.player_username) AS player_name, + COUNT(DISTINCT s.id) AS seasons_played, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, + COALESCE(SUM(frps.goals), 0) AS total_goals, + COALESCE(SUM(frps.assists), 0) AS total_assists, + COALESCE(SUM(frps.saves), 0) AS total_saves + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + JOIN fixtures f ON f.id = fr.fixture_id + JOIN seasons s ON s.id = f.season_id + LEFT JOIN players p ON p.id = frps.player_id + WHERE fr.finalized = true + AND frps.team_id = ? + AND frps.period_num = 3 + AND frps.player_id IS NOT NULL + GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) + `, teamID).Scan(ctx, &stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} diff --git a/internal/handlers/team_detail.go b/internal/handlers/team_detail.go new file mode 100644 index 0000000..7b607db --- /dev/null +++ b/internal/handlers/team_detail.go @@ -0,0 +1,67 @@ +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/throw" + teamsview "git.haelnorr.com/h/oslstats/internal/view/teamsview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// TeamDetailPage renders the global team detail page showing cross-season stats +func TeamDetailPage( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + teamIDStr := r.PathValue("team_id") + + teamID, err := strconv.Atoi(teamIDStr) + if err != nil { + throw.NotFound(s, w, r, r.URL.Path) + return + } + + var team *db.Team + var seasonInfos []*db.TeamSeasonInfo + var playerStats []*db.TeamAllTimePlayerStats + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + team, err = db.GetTeam(ctx, tx, teamID) + if err != nil { + if db.IsBadRequest(err) { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return false, errors.Wrap(err, "db.GetTeam") + } + + seasonInfos, err = db.GetTeamSeasonParticipation(ctx, tx, teamID) + if err != nil { + return false, errors.Wrap(err, "db.GetTeamSeasonParticipation") + } + + playerStats, err = db.GetTeamAllTimePlayerStats(ctx, tx, teamID) + if err != nil { + return false, errors.Wrap(err, "db.GetTeamAllTimePlayerStats") + } + + return true, nil + }); !ok { + return + } + + activeTab := r.URL.Query().Get("tab") + if activeTab != "seasons" && activeTab != "stats" { + activeTab = "seasons" + } + + renderSafely(teamsview.DetailPage(team, seasonInfos, playerStats, activeTab), s, r, w) + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 38fda14..9d797c5 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -316,6 +316,11 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.TeamsManagePlayers)(handlers.ManageTeamRoster(s, conn)), }, + { + Path: "/teams/{team_id}", + Method: hws.MethodGET, + Handler: handlers.TeamDetailPage(s, conn), + }, } htmxRoutes := []hws.Route{ diff --git a/internal/view/seasonsview/season_league_team_detail.templ b/internal/view/seasonsview/season_league_team_detail.templ index 83a0b84..c4420d0 100644 --- a/internal/view/seasonsview/season_league_team_detail.templ +++ b/internal/view/seasonsview/season_league_team_detail.templ @@ -42,13 +42,22 @@ templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture,
- - Back to Teams - + diff --git a/internal/view/teamsview/detail_page.templ b/internal/view/teamsview/detail_page.templ new file mode 100644 index 0000000..937bb26 --- /dev/null +++ b/internal/view/teamsview/detail_page.templ @@ -0,0 +1,81 @@ +package teamsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "fmt" + +templ DetailPage(team *db.Team, seasonInfos []*db.TeamSeasonInfo, playerStats []*db.TeamAllTimePlayerStats, activeTab string) { + @baseview.Layout(team.Name) { +
+
+ +
+
+
+ if team.Color != "" { +
+ } +
+

{ team.Name }

+
+ + { team.ShortName } + + + { team.AltShortName } + +
+
+
+ + Back to Teams + +
+
+ + +
+ +
+ if activeTab == "seasons" { + @TeamDetailSeasons(team, seasonInfos) + } else if activeTab == "stats" { + @TeamDetailPlayerStats(playerStats) + } +
+
+ } +} + +templ teamDetailTab(section string, label string, activeTab string, team *db.Team) { + {{ + isActive := section == activeTab + baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2" + activeClasses := "border-blue text-blue font-semibold" + inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2" + url := fmt.Sprintf("/teams/%d", team.ID) + if section != "seasons" { + url = fmt.Sprintf("/teams/%d?tab=%s", team.ID, section) + } + }} +
  • + + { label } + +
  • +} diff --git a/internal/view/teamsview/detail_player_stats.templ b/internal/view/teamsview/detail_player_stats.templ new file mode 100644 index 0000000..085c7cb --- /dev/null +++ b/internal/view/teamsview/detail_player_stats.templ @@ -0,0 +1,127 @@ +package teamsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" +import "sort" + +templ TeamDetailPlayerStats(playerStats []*db.TeamAllTimePlayerStats) { + if len(playerStats) == 0 { +
    +

    No player stats yet.

    +

    Player statistics will appear here once games are played.

    +
    + } else { +
    + +
    + + + +
    + +
    + @playerStatsTable(playerStats, "goals") +
    + +
    + @playerStatsTable(playerStats, "assists") +
    + +
    + @playerStatsTable(playerStats, "saves") +
    +
    + } +} + +templ playerStatsTable(playerStats []*db.TeamAllTimePlayerStats, statType string) { + {{ + // Make a copy so sorting doesn't affect other views + sorted := make([]*db.TeamAllTimePlayerStats, len(playerStats)) + copy(sorted, playerStats) + + switch statType { + case "goals": + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Goals > sorted[j].Goals + }) + case "assists": + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Assists > sorted[j].Assists + }) + case "saves": + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Saves > sorted[j].Saves + }) + } + + statLabel := "Goals" + statShort := "G" + if statType == "assists" { + statLabel = "Assists" + statShort = "A" + } else if statType == "saves" { + statLabel = "Saves" + statShort = "SV" + } + _ = statLabel + }} +
    +
    + + + + + + + + + + + + for i, ps := range sorted { + + + + + + if statType == "goals" { + + } else if statType == "assists" { + + } else { + + } + + } + +
    #PlayerSZNPP{ statShort }
    + { fmt.Sprint(i + 1) } + { ps.PlayerName }{ fmt.Sprint(ps.SeasonsPlayed) }{ fmt.Sprint(ps.PeriodsPlayed) }{ fmt.Sprint(ps.Goals) }{ fmt.Sprint(ps.Assists) }{ fmt.Sprint(ps.Saves) }
    +
    +
    +} diff --git a/internal/view/teamsview/detail_seasons.templ b/internal/view/teamsview/detail_seasons.templ new file mode 100644 index 0000000..0bcbe3c --- /dev/null +++ b/internal/view/teamsview/detail_seasons.templ @@ -0,0 +1,86 @@ +package teamsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/seasonsview" +import "fmt" + +templ TeamDetailSeasons(team *db.Team, seasonInfos []*db.TeamSeasonInfo) { + if len(seasonInfos) == 0 { +
    +

    No season history yet.

    +

    This team has not participated in any seasons.

    +
    + } else { +
    + for _, info := range seasonInfos { + @teamSeasonCard(team, info) + } +
    + } +} + +templ teamSeasonCard(team *db.Team, info *db.TeamSeasonInfo) { + {{ + detailURL := fmt.Sprintf( + "/seasons/%s/leagues/%s/teams/%d", + info.Season.ShortName, info.League.ShortName, team.ID, + ) + }} + + +
    +
    +

    { info.Season.Name }

    + โ€” + { info.League.Name } +
    + @seasonsview.StatusBadge(info.Season, true, true) +
    + +
    + +
    +
    + +
    + Position + + { fmt.Sprint(info.Position) } + + + / { fmt.Sprint(info.TotalTeams) } + +
    +
    + +
    + Points +

    { fmt.Sprint(info.Record.Points) }

    +
    +
    + +
    +
    +

    W

    +

    { fmt.Sprint(info.Record.Wins) }

    +
    +
    +

    OTW

    +

    { fmt.Sprint(info.Record.OvertimeWins) }

    +
    +
    +

    OTL

    +

    { fmt.Sprint(info.Record.OvertimeLosses) }

    +
    +
    +

    L

    +

    { fmt.Sprint(info.Record.Losses) }

    +
    +
    +
    +
    +} diff --git a/internal/view/teamsview/list_page.templ b/internal/view/teamsview/list_page.templ index 4d79a2c..de0eab4 100644 --- a/internal/view/teamsview/list_page.templ +++ b/internal/view/teamsview/list_page.templ @@ -7,6 +7,7 @@ import "git.haelnorr.com/h/oslstats/internal/view/sort" import "git.haelnorr.com/h/oslstats/internal/contexts" import "git.haelnorr.com/h/oslstats/internal/permissions" import "github.com/uptrace/bun" +import "fmt" templ ListPage(teams *db.List[db.Team]) { @baseview.Layout("Teams") { @@ -80,8 +81,10 @@ templ TeamsList(teams *db.List[db.Team]) {
    for _, t := range teams.Items { -
    @@ -102,7 +105,7 @@ templ TeamsList(teams *db.List[db.Team]) { { t.AltShortName }
    -
    + }
    From b57fbcd302f5c955a4a283a11a08401c75229038 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 19:06:29 +1100 Subject: [PATCH 06/33] updated team stats --- internal/embedfs/web/css/output.css | 13 +- .../handlers/season_league_team_detail.go | 44 ++- .../seasonsview/fixture_review_result.templ | 17 ++ .../season_league_team_detail.templ | 262 ++++++++++-------- internal/view/teamsview/detail_seasons.templ | 19 +- 5 files changed, 230 insertions(+), 125 deletions(-) diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index e04cc37..b856653 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -1152,6 +1152,9 @@ .py-4 { padding-block: calc(var(--spacing) * 4); } + .py-5 { + padding-block: calc(var(--spacing) * 5); + } .py-6 { padding-block: calc(var(--spacing) * 6); } @@ -2041,11 +2044,6 @@ scale: var(--tw-scale-x) var(--tw-scale-y); } } - .sm\:grid-cols-4 { - @media (width >= 40rem) { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - } .sm\:flex-row { @media (width >= 40rem) { flex-direction: row; @@ -2177,11 +2175,6 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } - .lg\:grid-cols-8 { - @media (width >= 64rem) { - grid-template-columns: repeat(8, minmax(0, 1fr)); - } - } .lg\:items-end { @media (width >= 64rem) { align-items: flex-end; diff --git a/internal/handlers/season_league_team_detail.go b/internal/handlers/season_league_team_detail.go index 5d55c03..48e0bb3 100644 --- a/internal/handlers/season_league_team_detail.go +++ b/internal/handlers/season_league_team_detail.go @@ -35,6 +35,7 @@ func SeasonLeagueTeamDetailPage( var scheduleMap map[int]*db.FixtureSchedule var resultMap map[int]*db.FixtureResult var playerStats []*db.AggregatedPlayerStats + var leaderboard []*db.LeaderboardEntry if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error @@ -72,12 +73,51 @@ func SeasonLeagueTeamDetailPage( return false, errors.Wrap(err, "db.GetPlayersNotOnTeam") } + // Get all teams and all fixtures for the league to compute leaderboard + var allTeams []*db.Team + err = tx.NewSelect(). + Model(&allTeams). + Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id"). + Where("tp.season_id = ? AND tp.league_id = ?", twr.Season.ID, twr.League.ID). + Scan(ctx) + if err != nil { + return false, errors.Wrap(err, "tx.NewSelect allTeams") + } + + allFixtures, err := db.GetAllocatedFixtures(ctx, tx, twr.Season.ID, twr.League.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetAllocatedFixtures") + } + allFixtureIDs := make([]int, len(allFixtures)) + for i, f := range allFixtures { + allFixtureIDs[i] = f.ID + } + allResultMap, err := db.GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs) + if err != nil { + return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures allFixtures") + } + + leaderboard = db.ComputeLeaderboard(allTeams, allFixtures, allResultMap) + return true, nil }); !ok { return } - record := db.ComputeTeamRecord(teamID, fixtures, resultMap) - renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available, scheduleMap, resultMap, record, playerStats), s, r, w) + // Find this team's position and record from the leaderboard + var position int + var record *db.TeamRecord + for _, entry := range leaderboard { + if entry.Team.ID == teamID { + position = entry.Position + record = entry.Record + break + } + } + if record == nil { + record = &db.TeamRecord{} + } + + renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available, scheduleMap, resultMap, record, playerStats, position, len(leaderboard)), s, r, w) }) } diff --git a/internal/view/seasonsview/fixture_review_result.templ b/internal/view/seasonsview/fixture_review_result.templ index e01796b..c41e931 100644 --- a/internal/view/seasonsview/fixture_review_result.templ +++ b/internal/view/seasonsview/fixture_review_result.templ @@ -260,3 +260,20 @@ func intPtrStr(v *int) string { } return fmt.Sprint(*v) } + +func ordinal(n int) string { + suffix := "th" + if n%100 >= 11 && n%100 <= 13 { + // 11th, 12th, 13th + } else { + switch n % 10 { + case 1: + suffix = "st" + case 2: + suffix = "nd" + case 3: + suffix = "rd" + } + } + return fmt.Sprintf("%d%s", n, suffix) +} diff --git a/internal/view/seasonsview/season_league_team_detail.templ b/internal/view/seasonsview/season_league_team_detail.templ index c4420d0..3019d01 100644 --- a/internal/view/seasonsview/season_league_team_detail.templ +++ b/internal/view/seasonsview/season_league_team_detail.templ @@ -8,7 +8,7 @@ import "fmt" import "sort" import "time" -templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult, record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) { +templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult, record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats, position int, totalTeams int) { {{ team := twr.Team season := twr.Season @@ -62,14 +62,48 @@ templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture,
    - + {{ + // Split fixtures into upcoming and completed + var upcoming []*db.Fixture + var completed []*db.Fixture + for _, f := range fixtures { + if _, hasResult := resultMap[f.ID]; hasResult { + completed = append(completed, f) + } else { + upcoming = append(upcoming, f) + } + } + // Sort completed by scheduled time descending (most recent first) + sort.Slice(completed, func(i, j int) bool { + ti := time.Time{} + tj := time.Time{} + if si, ok := scheduleMap[completed[i].ID]; ok && si.ScheduledTime != nil { + ti = *si.ScheduledTime + } + if sj, ok := scheduleMap[completed[j].ID]; ok && sj.ScheduledTime != nil { + tj = *sj.ScheduledTime + } + return ti.After(tj) + }) + // Limit to 5 most recent results + recentResults := completed + if len(recentResults) > 5 { + recentResults = recentResults[:5] + } + }}
    + + @teamRecordCard(record, position, totalTeams) + + @teamResultsSection(twr.Team, recentResults, resultMap) + @TeamRosterSection(twr, available) - @teamFixturesPane(twr.Team, fixtures, scheduleMap, resultMap) + + @teamUpcomingSection(twr.Team, upcoming, scheduleMap)
    - +
    - @teamStatsSection(record, playerStats) + @playerStatsSection(playerStats)
    @@ -405,68 +439,45 @@ templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPl } -templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) { - {{ - // Split fixtures into upcoming and completed - var upcoming []*db.Fixture - var completed []*db.Fixture - for _, f := range fixtures { - if _, hasResult := resultMap[f.ID]; hasResult { - completed = append(completed, f) - } else { - upcoming = append(upcoming, f) - } - } - // Sort completed by scheduled time descending (most recent first) - sort.Slice(completed, func(i, j int) bool { - ti := time.Time{} - tj := time.Time{} - if si, ok := scheduleMap[completed[i].ID]; ok && si.ScheduledTime != nil { - ti = *si.ScheduledTime - } - if sj, ok := scheduleMap[completed[j].ID]; ok && sj.ScheduledTime != nil { - tj = *sj.ScheduledTime - } - return ti.After(tj) - }) - // Limit to 5 most recent results - recentResults := completed - if len(recentResults) > 5 { - recentResults = recentResults[:5] - } - }} -
    - -
    -

    Results

    - if len(recentResults) == 0 { -
    -

    No results yet.

    -

    Match results will appear here once games are played.

    -
    - } else { -
    - for _, fixture := range recentResults { - @teamResultRow(team, fixture, resultMap) - } -
    - } +templ teamResultsSection(team *db.Team, recentResults []*db.Fixture, resultMap map[int]*db.FixtureResult) { +
    +
    +

    + Results + (last 5) +

    - -
    -

    Upcoming

    - if len(upcoming) == 0 { -
    -

    No upcoming fixtures.

    -
    - } else { -
    - for _, fixture := range upcoming { - @teamFixtureRow(team, fixture, scheduleMap) - } -
    - } + if len(recentResults) == 0 { +
    +

    No results yet.

    +

    Match results will appear here once games are played.

    +
    + } else { +
    + for _, fixture := range recentResults { + @teamResultRow(team, fixture, resultMap) + } +
    + } +
    +} + +templ teamUpcomingSection(team *db.Team, upcoming []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) { +
    +
    +

    Upcoming

    + if len(upcoming) == 0 { +
    +

    No upcoming fixtures.

    +
    + } else { +
    + for _, fixture := range upcoming { + @teamFixtureRow(team, fixture, scheduleMap) + } +
    + }
    } @@ -595,69 +606,96 @@ templ teamResultRow(team *db.Team, fixture *db.Fixture, resultMap map[int]*db.Fi } -templ teamStatsSection(record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) { +templ teamRecordCard(record *db.TeamRecord, position int, totalTeams int) {
    -
    -

    Stats

    +
    +

    Standing

    if record.Played == 0 {
    -

    No stats yet.

    -

    Team statistics will appear here once games are played.

    +

    No games played yet.

    } else { - -
    -
    - @statCell("Played", fmt.Sprint(record.Played), "") +
    + +
    +
    + { ordinal(position) } +
    +

    Position

    +

    of { fmt.Sprint(totalTeams) } teams

    +
    +
    +
    +

    Points

    +

    { fmt.Sprint(record.Points) }

    +
    +
    + +
    @statCell("W", fmt.Sprint(record.Wins), "text-green") @statCell("OTW", fmt.Sprint(record.OvertimeWins), "text-teal") @statCell("OTL", fmt.Sprint(record.OvertimeLosses), "text-peach") @statCell("L", fmt.Sprint(record.Losses), "text-red") +
    + +
    + @statCell("Played", fmt.Sprint(record.Played), "") @statCell("GF", fmt.Sprint(record.GoalsFor), "") @statCell("GA", fmt.Sprint(record.GoalsAgainst), "") - @statCell("PTS", fmt.Sprint(record.Points), "text-blue")
    - - if len(playerStats) > 0 { -
    -
    - - - - - - - - - - - - - + } + +} + +templ playerStatsSection(playerStats []*db.AggregatedPlayerStats) { +
    +
    +

    Player Stats

    +
    + if len(playerStats) == 0 { +
    +

    No player stats yet.

    +

    Player statistics will appear here once games are played.

    +
    + } else { +
    +
    +
    PlayerGPPPSCGASVSHBLPA
    + + + + + + + + + + + + + + + + for _, ps := range playerStats { + + + + + + + + + + + - - - for _, ps := range playerStats { - - - - - - - - - - - - - } - -
    PlayerGPPPSCGASVSHBLPA
    { ps.PlayerName }{ fmt.Sprint(ps.GamesPlayed) }{ fmt.Sprint(ps.PeriodsPlayed) }{ fmt.Sprint(ps.Score) }{ fmt.Sprint(ps.Goals) }{ fmt.Sprint(ps.Assists) }{ fmt.Sprint(ps.Saves) }{ fmt.Sprint(ps.Shots) }{ fmt.Sprint(ps.Blocks) }{ fmt.Sprint(ps.Passes) }
    { ps.PlayerName }{ fmt.Sprint(ps.GamesPlayed) }{ fmt.Sprint(ps.PeriodsPlayed) }{ fmt.Sprint(ps.Score) }{ fmt.Sprint(ps.Goals) }{ fmt.Sprint(ps.Assists) }{ fmt.Sprint(ps.Saves) }{ fmt.Sprint(ps.Shots) }{ fmt.Sprint(ps.Blocks) }{ fmt.Sprint(ps.Passes) }
    -
    + } + +
    - } +
    }
    } diff --git a/internal/view/teamsview/detail_seasons.templ b/internal/view/teamsview/detail_seasons.templ index 0bcbe3c..cfb5eeb 100644 --- a/internal/view/teamsview/detail_seasons.templ +++ b/internal/view/teamsview/detail_seasons.templ @@ -49,7 +49,7 @@ templ teamSeasonCard(team *db.Team, info *db.TeamSeasonInfo) {
    Position - { fmt.Sprint(info.Position) } + { ordinal(info.Position) } / { fmt.Sprint(info.TotalTeams) } @@ -84,3 +84,20 @@ templ teamSeasonCard(team *db.Team, info *db.TeamSeasonInfo) {
    } + +func ordinal(n int) string { + suffix := "th" + if n%100 >= 11 && n%100 <= 13 { + // 11th, 12th, 13th + } else { + switch n % 10 { + case 1: + suffix = "st" + case 2: + suffix = "nd" + case 3: + suffix = "rd" + } + } + return fmt.Sprintf("%d%s", n, suffix) +} From 788346d26922a713f742e4019da9abda8392ff4b Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 19:51:27 +1100 Subject: [PATCH 07/33] player profile added --- internal/embedfs/web/css/output.css | 15 +++ internal/handlers/player_link_slapid.go | 104 ++++++++++++++++++ internal/handlers/player_view.go | 84 ++++++++++++++ internal/handlers/register.go | 8 +- internal/server/routes.go | 19 ++++ internal/view/playersview/player_page.templ | 38 +++++++ .../view/playersview/slap_id_section.templ | 77 +++++++++++++ 7 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 internal/handlers/player_link_slapid.go create mode 100644 internal/handlers/player_view.go create mode 100644 internal/view/playersview/player_page.templ create mode 100644 internal/view/playersview/slap_id_section.templ diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index b856653..0901023 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -906,6 +906,12 @@ .border-green { border-color: var(--green); } + .border-green\/30 { + border-color: var(--green); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--green) 30%, transparent); + } + } .border-overlay0 { border-color: var(--overlay0); } @@ -1002,6 +1008,12 @@ .bg-green { background-color: var(--green); } + .bg-green\/10 { + background-color: var(--green); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--green) 10%, transparent); + } + } .bg-green\/20 { background-color: var(--green); @supports (color: color-mix(in lab, red, red)) { @@ -1371,6 +1383,9 @@ .italic { font-style: italic; } + .underline { + text-decoration-line: underline; + } .placeholder-subtext0 { &::placeholder { color: var(--subtext0); diff --git a/internal/handlers/player_link_slapid.go b/internal/handlers/player_link_slapid.go new file mode 100644 index 0000000..0f181b5 --- /dev/null +++ b/internal/handlers/player_link_slapid.go @@ -0,0 +1,104 @@ +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/throw" + playersview "git.haelnorr.com/h/oslstats/internal/view/playersview" + "git.haelnorr.com/h/oslstats/pkg/slapshotapi" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// LinkPlayerSlapID handles the HTMX POST request to link a player's Slapshot ID +// via their Discord Steam connection. Only the player's owner can trigger this. +func LinkPlayerSlapID( + s *hws.Server, + conn *db.DB, + slapAPI *slapshotapi.SlapAPI, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + playerIDStr := r.PathValue("player_id") + + playerID, err := strconv.Atoi(playerIDStr) + if err != nil { + throw.NotFound(s, w, r, r.URL.Path) + return + } + + var player *db.Player + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + user := db.CurrentUser(ctx) + if user == nil { + throw.Unauthorized(s, w, r, "You must be logged in", errors.New("user not authenticated")) + return false, nil + } + + var err error + player, err = db.GetPlayer(ctx, tx, playerID) + if err != nil { + if db.IsBadRequest(err) { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return false, errors.Wrap(err, "db.GetPlayer") + } + + // Verify the current user owns this player + if player.UserID == nil || *player.UserID != user.ID { + throw.ForbiddenSecurity(s, w, r, "You can only link your own player", errors.New("user does not own player")) + return false, nil + } + + // Player already has a SlapID + if player.SlapID != nil { + notify.Info(s, w, r, "Already Linked", "Your Slapshot ID is already linked", nil) + return false, nil + } + + // Get the user's discord token to look up steam connection + discordToken, err := user.GetDiscordToken(ctx, tx) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Link Failed", "Discord token not found. Please log out and log back in.", nil) + return false, nil + } + return false, errors.Wrap(err, "user.GetDiscordToken") + } + + audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user) + err = ConnectSlapID(ctx, tx, user, discordToken.Convert(), slapAPI, audit) + if err != nil { + return false, errors.Wrap(err, "ConnectSlapID") + } + + // Re-fetch the player to check if SlapID was set + player, err = db.GetPlayer(ctx, tx, playerID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayer") + } + + if player.SlapID == nil { + // ConnectSlapID returned nil (silent failure) - no steam or no slapID + notify.Warn(s, w, r, "Link Failed", + "Could not find your Slapshot ID. Make sure your Steam account is connected to Discord and you have played Slapshot: Rebound.", + nil) + } else { + notify.Success(s, w, r, "Success", "Your Slapshot ID has been linked!", nil) + } + + return true, nil + }); !ok { + return + } + + // Re-render the slap ID section with updated state + renderSafely(playersview.SlapIDSection(player, true), s, r, w) + }) +} diff --git a/internal/handlers/player_view.go b/internal/handlers/player_view.go new file mode 100644 index 0000000..11fbd80 --- /dev/null +++ b/internal/handlers/player_view.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/throw" + playersview "git.haelnorr.com/h/oslstats/internal/view/playersview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// ProfileRedirect redirects the authenticated user to their own player page. +func ProfileRedirect( + s *hws.Server, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := db.CurrentUser(r.Context()) + if user == nil { + throw.Unauthorized(s, w, r, "You must be logged in to view your profile", errors.New("user not authenticated")) + return + } + if user.Player == nil { + throw.InternalServiceError(s, w, r, "Player profile not found", errors.New("user has no linked player")) + return + } + http.Redirect(w, r, fmt.Sprintf("/players/%d", user.Player.ID), http.StatusSeeOther) + }) +} + +// PlayerView renders the player profile page. +// If the player has no SlapID and the viewer is the player's owner, show the link prompt. +// If the player has no SlapID and the viewer is not the owner, show 404. +func PlayerView( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + playerIDStr := r.PathValue("player_id") + + playerID, err := strconv.Atoi(playerIDStr) + if err != nil { + throw.NotFound(s, w, r, r.URL.Path) + return + } + + var player *db.Player + var isOwner bool + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + player, err = db.GetPlayer(ctx, tx, playerID) + if err != nil { + if db.IsBadRequest(err) { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return false, errors.Wrap(err, "db.GetPlayer") + } + + // Check if the current user owns this player + user := db.CurrentUser(ctx) + if user != nil && player.UserID != nil && *player.UserID == user.ID { + isOwner = true + } + + return true, nil + }); !ok { + return + } + + // If player has no SlapID and viewer is not the owner, show 404 + if player.SlapID == nil && !isOwner { + throw.NotFound(s, w, r, r.URL.Path) + return + } + + renderSafely(playersview.PlayerPage(player, isOwner), s, r, w) + }) +} diff --git a/internal/handlers/register.go b/internal/handlers/register.go index f787f05..61bbb24 100644 --- a/internal/handlers/register.go +++ b/internal/handlers/register.go @@ -73,7 +73,7 @@ func Register( if err != nil { return false, errors.Wrap(err, "registerUser") } - err = connectSlapID(ctx, tx, user, details.Token, slapAPI, audit) + err = ConnectSlapID(ctx, tx, user, details.Token, slapAPI, audit) if err != nil { return false, errors.Wrap(err, "connectSlapID") } @@ -123,11 +123,11 @@ func registerUser(ctx context.Context, tx bun.Tx, return user, nil } -func connectSlapID(ctx context.Context, tx bun.Tx, user *db.User, +// ConnectSlapID attempts to link a player's Slapshot ID via their Discord Steam connection. +// If fails due to no steam connection or no slapID, fails silently and returns nil. +func ConnectSlapID(ctx context.Context, tx bun.Tx, user *db.User, token *discord.Token, slapAPI *slapshotapi.SlapAPI, audit *db.AuditMeta, ) error { - // Attempt to setup their player/slapID from steam connection - // If fails due to no steam connection or no slapID, fail silently and proceed with registration session, err := discord.NewOAuthSession(token) if err != nil { return errors.Wrap(err, "discord.NewOAuthSession") diff --git a/internal/server/routes.go b/internal/server/routes.go index 9d797c5..c74bc5b 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -64,6 +64,11 @@ func addRoutes( Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, Handler: auth.LoginReq(handlers.Logout(s, auth, conn, discordAPI)), }, + { + Path: "/profile", + Method: hws.MethodGET, + Handler: auth.LoginReq(handlers.ProfileRedirect(s)), + }, } seasonRoutes := []hws.Route{ @@ -295,6 +300,19 @@ func addRoutes( }, } + playerRoutes := []hws.Route{ + { + Path: "/players/{player_id}", + Method: hws.MethodGET, + Handler: handlers.PlayerView(s, conn), + }, + { + Path: "/players/{player_id}/link-slapid", + Method: hws.MethodPOST, + Handler: auth.LoginReq(handlers.LinkPlayerSlapID(s, conn, slapAPI)), + }, + } + teamRoutes := []hws.Route{ { Path: "/teams", @@ -468,6 +486,7 @@ func addRoutes( routes = append(routes, leagueRoutes...) routes = append(routes, fixturesRoutes...) routes = append(routes, teamRoutes...) + routes = append(routes, playerRoutes...) // Register the routes with the server err := s.AddRoutes(routes...) diff --git a/internal/view/playersview/player_page.templ b/internal/view/playersview/player_page.templ new file mode 100644 index 0000000..c302f9b --- /dev/null +++ b/internal/view/playersview/player_page.templ @@ -0,0 +1,38 @@ +package playersview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "fmt" + +templ PlayerPage(player *db.Player, isOwner bool) { + @baseview.Layout(player.DisplayName() + " - Player Profile") { +
    +
    + +
    +
    +
    +

    { player.DisplayName() }

    +
    + if player.SlapID != nil { + + Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) } + + } + if isOwner { + + Your Profile + + } +
    +
    +
    +
    + +
    + @SlapIDSection(player, isOwner) +
    +
    +
    + } +} diff --git a/internal/view/playersview/slap_id_section.templ b/internal/view/playersview/slap_id_section.templ new file mode 100644 index 0000000..2e13f0a --- /dev/null +++ b/internal/view/playersview/slap_id_section.templ @@ -0,0 +1,77 @@ +package playersview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" + +templ SlapIDSection(player *db.Player, isOwner bool) { +
    + if player.SlapID == nil && isOwner { + @slapIDLinkPrompt(player) + } else if player.SlapID != nil { + @slapIDLinked(player) + } +
    +} + +templ slapIDLinkPrompt(player *db.Player) { +
    +
    + + + +
    +

    Slapshot ID Not Linked

    +

    + Your Slapshot ID is not linked. Please link your Steam account to your Discord account, then click the button below to connect your Slapshot ID. +

    +

    + Need help linking Steam to Discord? + + Follow this guide + +

    + +
    +
    +
    +} + +templ slapIDLinked(player *db.Player) { +
    +
    + + + + + Slapshot ID linked: + + if player.SlapID != nil { + { fmt.Sprintf("%d", *player.SlapID) } + } + + +
    +
    +} From 060301f2c21b23e95d62e0f18675d00c201ecc8f Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 20:48:21 +1100 Subject: [PATCH 08/33] added player stats to profile --- internal/db/player.go | 237 ++++++++++++++++++ internal/embedfs/web/css/output.css | 23 +- internal/handlers/player_stats_filter.go | 93 +++++++ internal/handlers/player_view.go | 156 ++++++++++-- internal/server/routes.go | 22 +- internal/view/playersview/player_page.templ | 85 ++++++- .../view/playersview/player_seasons_tab.templ | 73 ++++++ .../view/playersview/player_stats_tab.templ | 130 ++++++++++ .../view/playersview/player_teams_tab.templ | 51 ++++ .../view/playersview/slap_id_section.templ | 25 -- 10 files changed, 817 insertions(+), 78 deletions(-) create mode 100644 internal/handlers/player_stats_filter.go create mode 100644 internal/view/playersview/player_seasons_tab.templ create mode 100644 internal/view/playersview/player_stats_tab.templ create mode 100644 internal/view/playersview/player_teams_tab.templ diff --git a/internal/db/player.go b/internal/db/player.go index 7577b2d..48466e7 100644 --- a/internal/db/player.go +++ b/internal/db/player.go @@ -99,6 +99,243 @@ func UpdatePlayerSlapID(ctx context.Context, tx bun.Tx, playerID int, slapID uin return nil } +// PlayerAllTimeStats holds aggregated all-time stats for a single player +type PlayerAllTimeStats struct { + GamesPlayed int `bun:"games_played"` + PeriodsPlayed int `bun:"total_periods_played"` + Goals int `bun:"total_goals"` + Assists int `bun:"total_assists"` + Saves int `bun:"total_saves"` + Shots int `bun:"total_shots"` + Blocks int `bun:"total_blocks"` + Passes int `bun:"total_passes"` +} + +// GetPlayerAllTimeStats returns aggregated all-time stats for a player +// across all finalized fixture results (period 3 totals). +func GetPlayerAllTimeStats(ctx context.Context, tx bun.Tx, playerID int) (*PlayerAllTimeStats, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + stats := new(PlayerAllTimeStats) + err := tx.NewRaw(` + SELECT + COUNT(DISTINCT frps.fixture_result_id) AS games_played, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, + COALESCE(SUM(frps.goals), 0) AS total_goals, + COALESCE(SUM(frps.assists), 0) AS total_assists, + COALESCE(SUM(frps.saves), 0) AS total_saves, + COALESCE(SUM(frps.shots), 0) AS total_shots, + COALESCE(SUM(frps.blocks), 0) AS total_blocks, + COALESCE(SUM(frps.passes), 0) AS total_passes + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + WHERE fr.finalized = true + AND frps.player_id = ? + AND frps.period_num = 3 + `, playerID).Scan(ctx, stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + +// GetPlayerStatsBySeason returns aggregated stats for a player filtered by season. +func GetPlayerStatsBySeason(ctx context.Context, tx bun.Tx, playerID, seasonID int) (*PlayerAllTimeStats, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + if seasonID == 0 { + return nil, errors.New("seasonID not provided") + } + stats := new(PlayerAllTimeStats) + err := tx.NewRaw(` + SELECT + COUNT(DISTINCT frps.fixture_result_id) AS games_played, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, + COALESCE(SUM(frps.goals), 0) AS total_goals, + COALESCE(SUM(frps.assists), 0) AS total_assists, + COALESCE(SUM(frps.saves), 0) AS total_saves, + COALESCE(SUM(frps.shots), 0) AS total_shots, + COALESCE(SUM(frps.blocks), 0) AS total_blocks, + COALESCE(SUM(frps.passes), 0) AS total_passes + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + JOIN fixtures f ON f.id = fr.fixture_id + WHERE fr.finalized = true + AND frps.player_id = ? + AND frps.period_num = 3 + AND f.season_id = ? + `, playerID, seasonID).Scan(ctx, stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + +// GetPlayerStatsByTeam returns aggregated stats for a player filtered by team. +func GetPlayerStatsByTeam(ctx context.Context, tx bun.Tx, playerID, teamID int) (*PlayerAllTimeStats, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + if teamID == 0 { + return nil, errors.New("teamID not provided") + } + stats := new(PlayerAllTimeStats) + err := tx.NewRaw(` + SELECT + COUNT(DISTINCT frps.fixture_result_id) AS games_played, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, + COALESCE(SUM(frps.goals), 0) AS total_goals, + COALESCE(SUM(frps.assists), 0) AS total_assists, + COALESCE(SUM(frps.saves), 0) AS total_saves, + COALESCE(SUM(frps.shots), 0) AS total_shots, + COALESCE(SUM(frps.blocks), 0) AS total_blocks, + COALESCE(SUM(frps.passes), 0) AS total_passes + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + WHERE fr.finalized = true + AND frps.player_id = ? + AND frps.period_num = 3 + AND frps.team_id = ? + `, playerID, teamID).Scan(ctx, stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + +// PlayerTeamInfo holds a team the player has played on and how many seasons +type PlayerTeamInfo struct { + Team *Team + SeasonsCount int +} + +// GetPlayerTeams returns all teams the player has been rostered on, +// with a count of distinct seasons per team. +func GetPlayerTeams(ctx context.Context, tx bun.Tx, playerID int) ([]*PlayerTeamInfo, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + type teamRow struct { + TeamID int `bun:"team_id"` + SeasonsCount int `bun:"seasons_count"` + Name string `bun:"name"` + ShortName string `bun:"short_name"` + AltShortName string `bun:"alt_short_name"` + Color string `bun:"color"` + } + var rows []teamRow + err := tx.NewRaw(` + SELECT + t.id AS team_id, + t.name AS name, + t.short_name AS short_name, + t.alt_short_name AS alt_short_name, + t.color AS color, + COUNT(DISTINCT tr.season_id) AS seasons_count + FROM team_rosters tr + JOIN teams t ON t.id = tr.team_id + WHERE tr.player_id = ? + GROUP BY t.id, t.name, t.short_name, t.alt_short_name, t.color + ORDER BY seasons_count DESC + `, playerID).Scan(ctx, &rows) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + + var results []*PlayerTeamInfo + for _, row := range rows { + results = append(results, &PlayerTeamInfo{ + Team: &Team{ + ID: row.TeamID, + Name: row.Name, + ShortName: row.ShortName, + AltShortName: row.AltShortName, + Color: row.Color, + }, + SeasonsCount: row.SeasonsCount, + }) + } + return results, nil +} + +// PlayerSeasonInfo holds info about a player's participation in a specific season +type PlayerSeasonInfo struct { + Season *Season + League *League + Team *Team + IsManager bool +} + +// GetPlayerSeasons returns all season/league/team combos the player has been rostered in. +func GetPlayerSeasons(ctx context.Context, tx bun.Tx, playerID int) ([]*PlayerSeasonInfo, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + var rosters []*TeamRoster + err := tx.NewSelect(). + Model(&rosters). + Where("tr.player_id = ?", playerID). + Relation("Season"). + Relation("League"). + Relation("Team"). + Order("season.start_date DESC"). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect") + } + + var results []*PlayerSeasonInfo + for _, r := range rosters { + results = append(results, &PlayerSeasonInfo{ + Season: r.Season, + League: r.League, + Team: r.Team, + IsManager: r.IsManager, + }) + } + return results, nil +} + +// GetPlayerSeasonsList returns distinct seasons the player has participated in (for filter dropdowns). +func GetPlayerSeasonsList(ctx context.Context, tx bun.Tx, playerID int) ([]*Season, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + var seasons []*Season + err := tx.NewSelect(). + Model(&seasons). + Join("JOIN team_rosters tr ON tr.season_id = s.id"). + Where("tr.player_id = ?", playerID). + GroupExpr("s.id"). + Order("s.start_date DESC"). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect") + } + return seasons, nil +} + +// GetPlayerTeamsList returns distinct teams the player has played on (for filter dropdowns). +func GetPlayerTeamsList(ctx context.Context, tx bun.Tx, playerID int) ([]*Team, error) { + if playerID == 0 { + return nil, errors.New("playerID not provided") + } + var teams []*Team + err := tx.NewSelect(). + Model(&teams). + Join("JOIN team_rosters tr ON tr.team_id = t.id"). + Where("tr.player_id = ?", playerID). + GroupExpr("t.id"). + Order("t.name ASC"). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect") + } + return teams, nil +} + func GetPlayersNotOnTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Player, error) { players, err := GetList[Player](tx).Relation("User"). Join("LEFT JOIN team_rosters tr ON tr.player_id = p.id"). diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 0901023..5d03bb1 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -906,12 +906,6 @@ .border-green { border-color: var(--green); } - .border-green\/30 { - border-color: var(--green); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--green) 30%, transparent); - } - } .border-overlay0 { border-color: var(--overlay0); } @@ -1008,12 +1002,6 @@ .bg-green { background-color: var(--green); } - .bg-green\/10 { - background-color: var(--green); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--green) 10%, transparent); - } - } .bg-green\/20 { background-color: var(--green); @supports (color: color-mix(in lab, red, red)) { @@ -1317,6 +1305,9 @@ .text-mantle { color: var(--mantle); } + .text-mauve { + color: var(--mauve); + } .text-overlay0 { color: var(--overlay0); } @@ -1338,6 +1329,9 @@ color: color-mix(in oklab, var(--red) 80%, transparent); } } + .text-sky { + color: var(--sky); + } .text-subtext0 { color: var(--subtext0); } @@ -2059,6 +2053,11 @@ scale: var(--tw-scale-x) var(--tw-scale-y); } } + .sm\:grid-cols-4 { + @media (width >= 40rem) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } .sm\:flex-row { @media (width >= 40rem) { flex-direction: row; diff --git a/internal/handlers/player_stats_filter.go b/internal/handlers/player_stats_filter.go new file mode 100644 index 0000000..531b6b5 --- /dev/null +++ b/internal/handlers/player_stats_filter.go @@ -0,0 +1,93 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + playersview "git.haelnorr.com/h/oslstats/internal/view/playersview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// PlayerStatsFilter handles HTMX POST requests to filter player stats +// by season or team. Only one filter can be active at a time. +// Query params: filter=season|team, filter_id= +func PlayerStatsFilter( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + player, _, ok := resolvePlayerAndOwner(s, conn, w, r) + if !ok { + return + } + + filterType := r.URL.Query().Get("filter") + filterIDStr := r.URL.Query().Get("filter_id") + + var stats *db.PlayerAllTimeStats + var seasons []*db.Season + var teams []*db.Team + var activeFilter string + var activeFilterID int + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + + // Load filter dropdown data + seasons, err = db.GetPlayerSeasonsList(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerSeasonsList") + } + teams, err = db.GetPlayerTeamsList(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerTeamsList") + } + + // Apply filter + filterID, _ := strconv.Atoi(filterIDStr) + switch filterType { + case "season": + if filterID > 0 { + stats, err = db.GetPlayerStatsBySeason(ctx, tx, player.ID, filterID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerStatsBySeason") + } + activeFilter = "season" + activeFilterID = filterID + } + case "team": + if filterID > 0 { + stats, err = db.GetPlayerStatsByTeam(ctx, tx, player.ID, filterID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerStatsByTeam") + } + activeFilter = "team" + activeFilterID = filterID + } + } + + // Default to all-time stats if no valid filter + if stats == nil { + stats, err = db.GetPlayerAllTimeStats(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerAllTimeStats") + } + activeFilter = "" + activeFilterID = 0 + } + + return true, nil + }); !ok { + return + } + + renderSafely(playersview.PlayerStatsTab( + player, stats, seasons, teams, + activeFilter, activeFilterID, + ), s, r, w) + }) +} diff --git a/internal/handlers/player_view.go b/internal/handlers/player_view.go index 11fbd80..a8a15aa 100644 --- a/internal/handlers/player_view.go +++ b/internal/handlers/player_view.go @@ -32,53 +32,155 @@ func ProfileRedirect( }) } -// PlayerView renders the player profile page. -// If the player has no SlapID and the viewer is the player's owner, show the link prompt. -// If the player has no SlapID and the viewer is not the owner, show 404. -func PlayerView( +// resolvePlayerAndOwner is a helper that resolves the player from the URL path +// and determines if the current user is the owner of the player. +// Returns false from the outer handler if resolution failed (404 already thrown). +func resolvePlayerAndOwner( + s *hws.Server, + conn *db.DB, + w http.ResponseWriter, + r *http.Request, +) (player *db.Player, isOwner bool, ok bool) { + playerIDStr := r.PathValue("player_id") + playerID, err := strconv.Atoi(playerIDStr) + if err != nil { + throw.NotFound(s, w, r, r.URL.Path) + return nil, false, false + } + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + player, err = db.GetPlayer(ctx, tx, playerID) + if err != nil { + if db.IsBadRequest(err) { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return false, errors.Wrap(err, "db.GetPlayer") + } + + user := db.CurrentUser(ctx) + if user != nil && player.UserID != nil && *player.UserID == user.ID { + isOwner = true + } + + return true, nil + }); !ok { + return nil, false, false + } + + // If player has no SlapID and viewer is not the owner, show 404 + if player.SlapID == nil && !isOwner { + throw.NotFound(s, w, r, r.URL.Path) + return nil, false, false + } + + return player, isOwner, true +} + +// PlayerViewStats renders the player profile page with the stats tab active. +// GET renders the full page layout. POST renders just the tab content. +func PlayerViewStats( s *hws.Server, conn *db.DB, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - playerIDStr := r.PathValue("player_id") - - playerID, err := strconv.Atoi(playerIDStr) - if err != nil { - throw.NotFound(s, w, r, r.URL.Path) + player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r) + if !ok { return } - var player *db.Player - var isOwner bool + var stats *db.PlayerAllTimeStats + var seasons []*db.Season + var teams []*db.Team if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error - player, err = db.GetPlayer(ctx, tx, playerID) + stats, err = db.GetPlayerAllTimeStats(ctx, tx, player.ID) if err != nil { - if db.IsBadRequest(err) { - throw.NotFound(s, w, r, r.URL.Path) - return false, nil - } - return false, errors.Wrap(err, "db.GetPlayer") + return false, errors.Wrap(err, "db.GetPlayerAllTimeStats") } - - // Check if the current user owns this player - user := db.CurrentUser(ctx) - if user != nil && player.UserID != nil && *player.UserID == user.ID { - isOwner = true + seasons, err = db.GetPlayerSeasonsList(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerSeasonsList") + } + teams, err = db.GetPlayerTeamsList(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerTeamsList") } - return true, nil }); !ok { return } - // If player has no SlapID and viewer is not the owner, show 404 - if player.SlapID == nil && !isOwner { - throw.NotFound(s, w, r, r.URL.Path) + if r.Method == "GET" { + renderSafely(playersview.PlayerStatsPage(player, isOwner, stats, seasons, teams), s, r, w) + } else { + renderSafely(playersview.PlayerStatsTab(player, stats, seasons, teams, "", 0), s, r, w) + } + }) +} + +// PlayerViewTeams renders the teams tab of the player profile page. +func PlayerViewTeams( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r) + if !ok { return } - renderSafely(playersview.PlayerPage(player, isOwner), s, r, w) + var teamInfos []*db.PlayerTeamInfo + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + teamInfos, err = db.GetPlayerTeams(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerTeams") + } + return true, nil + }); !ok { + return + } + + if r.Method == "GET" { + renderSafely(playersview.PlayerTeamsPage(player, isOwner, teamInfos), s, r, w) + } else { + renderSafely(playersview.PlayerTeamsTab(teamInfos), s, r, w) + } + }) +} + +// PlayerViewSeasons renders the seasons tab of the player profile page. +func PlayerViewSeasons( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + player, isOwner, ok := resolvePlayerAndOwner(s, conn, w, r) + if !ok { + return + } + + var seasonInfos []*db.PlayerSeasonInfo + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + seasonInfos, err = db.GetPlayerSeasons(ctx, tx, player.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayerSeasons") + } + return true, nil + }); !ok { + return + } + + if r.Method == "GET" { + renderSafely(playersview.PlayerSeasonsPage(player, isOwner, seasonInfos), s, r, w) + } else { + renderSafely(playersview.PlayerSeasonsTab(seasonInfos), s, r, w) + } }) } diff --git a/internal/server/routes.go b/internal/server/routes.go index c74bc5b..80978fc 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -304,7 +304,27 @@ func addRoutes( { Path: "/players/{player_id}", Method: hws.MethodGET, - Handler: handlers.PlayerView(s, conn), + Handler: handlers.PlayerViewStats(s, conn), + }, + { + Path: "/players/{player_id}/stats", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.PlayerViewStats(s, conn), + }, + { + Path: "/players/{player_id}/teams", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.PlayerViewTeams(s, conn), + }, + { + Path: "/players/{player_id}/seasons", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.PlayerViewSeasons(s, conn), + }, + { + Path: "/players/{player_id}/stats/filter", + Method: hws.MethodPOST, + Handler: handlers.PlayerStatsFilter(s, conn), }, { Path: "/players/{player_id}/link-slapid", diff --git a/internal/view/playersview/player_page.templ b/internal/view/playersview/player_page.templ index c302f9b..9024d96 100644 --- a/internal/view/playersview/player_page.templ +++ b/internal/view/playersview/player_page.templ @@ -4,35 +4,94 @@ import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/view/baseview" import "fmt" -templ PlayerPage(player *db.Player, isOwner bool) { +templ PlayerLayout(activeSection string, player *db.Player, isOwner bool) { @baseview.Layout(player.DisplayName() + " - Player Profile") { -
    +
    -

    { player.DisplayName() }

    -
    - if player.SlapID != nil { - - Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) } - - } +
    +

    { player.DisplayName() }

    if isOwner { Your Profile }
    +
    + if player.SlapID != nil { + + Slapshot ID: { fmt.Sprintf("%d", *player.SlapID) } + + } +
    - -
    - @SlapIDSection(player, isOwner) -
    + + if player.SlapID == nil && isOwner { +
    + @SlapIDSection(player, isOwner) +
    + } + + + +
    + { children... } +
    + + } +} + +templ playerNavItem(section string, label string, activeSection string, player *db.Player) { + {{ + isActive := section == activeSection + baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2" + activeClasses := "border-blue text-blue font-semibold" + inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2" + url := fmt.Sprintf("/players/%d/%s", player.ID, section) + }} +
  • + + { label } + +
  • +} + +// Full page wrappers (for GET requests / direct navigation) + +templ PlayerStatsPage(player *db.Player, isOwner bool, stats *db.PlayerAllTimeStats, seasons []*db.Season, teams []*db.Team) { + @PlayerLayout("stats", player, isOwner) { + @PlayerStatsTab(player, stats, seasons, teams, "", 0) + } +} + +templ PlayerTeamsPage(player *db.Player, isOwner bool, teamInfos []*db.PlayerTeamInfo) { + @PlayerLayout("teams", player, isOwner) { + @PlayerTeamsTab(teamInfos) + } +} + +templ PlayerSeasonsPage(player *db.Player, isOwner bool, seasonInfos []*db.PlayerSeasonInfo) { + @PlayerLayout("seasons", player, isOwner) { + @PlayerSeasonsTab(seasonInfos) } } diff --git a/internal/view/playersview/player_seasons_tab.templ b/internal/view/playersview/player_seasons_tab.templ new file mode 100644 index 0000000..4be3983 --- /dev/null +++ b/internal/view/playersview/player_seasons_tab.templ @@ -0,0 +1,73 @@ +package playersview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" + +templ PlayerSeasonsTab(seasonInfos []*db.PlayerSeasonInfo) { + if len(seasonInfos) == 0 { +
    +

    No season history yet.

    +

    This player has not participated in any seasons.

    +
    + } else { +
    +
    + + + + + + + + + + + for _, info := range seasonInfos { + + + + + + + } + +
    SeasonLeagueTeamRole
    + + { info.Season.Name } + + + { info.League.Name } + + +
    + if info.Team.Color != "" { +
    + } + { info.Team.Name } +
    +
    +
    + if info.IsManager { + + Manager + + } else { + Player + } +
    +
    +
    + } +} diff --git a/internal/view/playersview/player_stats_tab.templ b/internal/view/playersview/player_stats_tab.templ new file mode 100644 index 0000000..021cd11 --- /dev/null +++ b/internal/view/playersview/player_stats_tab.templ @@ -0,0 +1,130 @@ +package playersview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" + +templ PlayerStatsTab(player *db.Player, stats *db.PlayerAllTimeStats, seasons []*db.Season, teams []*db.Team, activeFilter string, activeFilterID int) { +
    + +
    + +
    + + +
    + +
    + + +
    +
    + +
    + if activeFilter == "" { + Showing All-Time stats + } else if activeFilter == "season" { + Showing stats for season: + + { getSeasonName(seasons, activeFilterID) } + + } else if activeFilter == "team" { + Showing stats for team: + + { getTeamName(teams, activeFilterID) } + + } +
    + + @playerStatsGrid(stats) +
    +} + +templ playerStatsGrid(stats *db.PlayerAllTimeStats) { +
    + @statCard("Games Played", fmt.Sprint(stats.GamesPlayed), "text-blue") + @statCard("Goals", fmt.Sprint(stats.Goals), "text-green") + @statCard("Assists", fmt.Sprint(stats.Assists), "text-teal") + @statCard("Saves", fmt.Sprint(stats.Saves), "text-yellow") + @statCard("Shots", fmt.Sprint(stats.Shots), "text-peach") + @statCard("Blocks", fmt.Sprint(stats.Blocks), "text-mauve") + @statCard("Passes", fmt.Sprint(stats.Passes), "text-sky") + @statCard("Periods Played", fmt.Sprint(stats.PeriodsPlayed), "text-subtext0") +
    +} + +templ statCard(label string, value string, colorClass string) { +
    +

    { label }

    +

    { value }

    +
    +} + +script handleFilterChange(filterType string) { + var container = event.target.closest("[data-filter-url]") + if (!container) return + + var baseUrl = container.getAttribute("data-filter-url") + var seasonSelect = container.querySelector("select[name='season_id']") + var teamSelect = container.querySelector("select[name='team_id']") + + // Reset the other filter when one is selected + if (filterType === "season" && teamSelect) { + teamSelect.value = "" + } else if (filterType === "team" && seasonSelect) { + seasonSelect.value = "" + } + + var value = event.target.value + var url = baseUrl + if (value) { + url += "?filter=" + filterType + "&filter_id=" + value + } + + htmx.ajax("POST", url, {target: "#player-content", swap: "innerHTML"}) +} + +func getSeasonName(seasons []*db.Season, id int) string { + for _, s := range seasons { + if s.ID == id { + return s.Name + } + } + return "Unknown" +} + +func getTeamName(teams []*db.Team, id int) string { + for _, t := range teams { + if t.ID == id { + return t.Name + } + } + return "Unknown" +} diff --git a/internal/view/playersview/player_teams_tab.templ b/internal/view/playersview/player_teams_tab.templ new file mode 100644 index 0000000..7182b8a --- /dev/null +++ b/internal/view/playersview/player_teams_tab.templ @@ -0,0 +1,51 @@ +package playersview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" + +templ PlayerTeamsTab(teamInfos []*db.PlayerTeamInfo) { + if len(teamInfos) == 0 { +
    +

    No team history yet.

    +

    This player has not been on any teams.

    +
    + } else { +
    +
    + + + + + + + + + for _, info := range teamInfos { + + + + + } + +
    TeamSeasons Played
    + +
    + if info.Team.Color != "" { +
    + } + { info.Team.Name } +
    +
    +
    + { fmt.Sprint(info.SeasonsCount) } +
    +
    +
    + } +} diff --git a/internal/view/playersview/slap_id_section.templ b/internal/view/playersview/slap_id_section.templ index 2e13f0a..1be519b 100644 --- a/internal/view/playersview/slap_id_section.templ +++ b/internal/view/playersview/slap_id_section.templ @@ -7,8 +7,6 @@ templ SlapIDSection(player *db.Player, isOwner bool) {
    if player.SlapID == nil && isOwner { @slapIDLinkPrompt(player) - } else if player.SlapID != nil { - @slapIDLinked(player) }
    } @@ -52,26 +50,3 @@ templ slapIDLinkPrompt(player *db.Player) {
    } - -templ slapIDLinked(player *db.Player) { -
    -
    - - - - - Slapshot ID linked: - - if player.SlapID != nil { - { fmt.Sprintf("%d", *player.SlapID) } - } - - -
    -
    -} From 76987adcebc9782ddbb16b4d409416d53765dd99 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 21:03:51 +1100 Subject: [PATCH 09/33] added better links to teams and players --- internal/handlers/season_league_table.go | 2 +- internal/view/component/links/links.templ | 54 +++++++++++++++++++ .../view/seasonsview/fixture_detail.templ | 31 ++++++----- .../seasonsview/fixture_review_result.templ | 33 ++++++++---- .../season_league_free_agents.templ | 5 +- .../seasonsview/season_league_table.templ | 19 +++---- .../season_league_team_detail.templ | 11 ++-- .../view/teamsview/detail_player_stats.templ | 5 +- 8 files changed, 117 insertions(+), 43 deletions(-) create mode 100644 internal/view/component/links/links.templ diff --git a/internal/handlers/season_league_table.go b/internal/handlers/season_league_table.go index c870109..a9fb7be 100644 --- a/internal/handlers/season_league_table.go +++ b/internal/handlers/season_league_table.go @@ -62,7 +62,7 @@ func SeasonLeagueTablePage( if r.Method == "GET" { renderSafely(seasonsview.SeasonLeagueTablePage(season, league, leaderboard), s, r, w) } else { - renderSafely(seasonsview.SeasonLeagueTable(leaderboard), s, r, w) + renderSafely(seasonsview.SeasonLeagueTable(season, league, leaderboard), s, r, w) } }) } diff --git a/internal/view/component/links/links.templ b/internal/view/component/links/links.templ new file mode 100644 index 0000000..ed6245e --- /dev/null +++ b/internal/view/component/links/links.templ @@ -0,0 +1,54 @@ +package links + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" + +// PlayerLink renders a player name as a clickable link to their profile page. +// The player's DisplayName() is used as the link text. +templ PlayerLink(player *db.Player) { + + { player.DisplayName() } + +} + +// PlayerLinkFromStats renders a player name link using a player ID and name string. +// This is useful when only aggregated stats are available (no full Player object). +templ PlayerLinkFromStats(playerID int, playerName string) { + + { playerName } + +} + +// TeamLinkInSeason renders a team name as a clickable link to the team's +// season-specific detail page, with an optional color dot prefix. +templ TeamLinkInSeason(team *db.Team, season *db.Season, league *db.League) { + + if team.Color != "" { + + } + { team.Name } + +} + +// TeamNameLinkInSeason renders just the team name as a clickable link (no color dot). +// Useful where the color dot is already rendered separately or in inline contexts. +templ TeamNameLinkInSeason(team *db.Team, season *db.Season, league *db.League) { + + { team.Name } + +} diff --git a/internal/view/seasonsview/fixture_detail.templ b/internal/view/seasonsview/fixture_detail.templ index c9d34cc..26e8b77 100644 --- a/internal/view/seasonsview/fixture_detail.templ +++ b/internal/view/seasonsview/fixture_detail.templ @@ -4,6 +4,7 @@ import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/permissions" import "git.haelnorr.com/h/oslstats/internal/contexts" import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" import "fmt" import "sort" import "strings" @@ -147,8 +148,8 @@ templ fixtureOverviewTab( }
    - @fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result) - @fixtureTeamSection(fixture.AwayTeam, rosters["away"], "away", result) + @fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result, fixture.Season, fixture.League) + @fixtureTeamSection(fixture.AwayTeam, rosters["away"], "away", result, fixture.Season, fixture.League)
    } @@ -603,7 +604,7 @@ templ forfeitModal(fixture *db.Fixture) { } -templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side string, result *db.FixtureResult) { +templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side string, result *db.FixtureResult, season *db.Season, league *db.League) { {{ // Separate playing and bench players var playing []*db.PlayerWithPlayStatus @@ -640,8 +641,8 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side }}
    -

    - { team.Name } +

    + @links.TeamNameLinkInSeason(team, season, league)

    if team.Color != "" { for _, p := range playing { - + - { p.Player.DisplayName() } + @links.PlayerLink(p.Player) if p.IsManager { ★ @@ -715,7 +716,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side for _, p := range bench {
    - { p.Player.DisplayName() } + @links.PlayerLink(p.Player) if p.IsManager { @@ -737,8 +738,8 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
    for _, p := range playing {
    - - { p.Player.DisplayName() } + + @links.PlayerLink(p.Player) if p.IsManager { @@ -760,7 +761,7 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side for _, p := range bench {
    - { p.Player.DisplayName() } + @links.PlayerLink(p.Player) if p.IsManager { @@ -840,7 +841,9 @@ templ fixtureFreeAgentSection( for _, n := range homeNominated {
    - { n.Player.DisplayName() } + + @links.PlayerLink(n.Player) + FA @@ -875,7 +878,9 @@ templ fixtureFreeAgentSection( for _, n := range awayNominated {
    - { n.Player.DisplayName() } + + @links.PlayerLink(n.Player) + FA diff --git a/internal/view/seasonsview/fixture_review_result.templ b/internal/view/seasonsview/fixture_review_result.templ index c41e931..2921621 100644 --- a/internal/view/seasonsview/fixture_review_result.templ +++ b/internal/view/seasonsview/fixture_review_result.templ @@ -2,6 +2,7 @@ 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" templ FixtureReviewResultPage( @@ -22,7 +23,13 @@ templ FixtureReviewResultPage(

    Review Match Result

    - { fixture.HomeTeam.Name } vs { fixture.AwayTeam.Name } + + @links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League) + + vs + + @links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League) + Round { fmt.Sprint(fixture.Round) } @@ -96,12 +103,16 @@ templ FixtureReviewResultPage(

    -

    { fixture.HomeTeam.Name }

    +

    + @links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League) +

    { fmt.Sprint(result.HomeScore) }

    โ€”
    -

    { fixture.AwayTeam.Name }

    +

    + @links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League) +

    { fmt.Sprint(result.AwayScore) }

    @@ -127,8 +138,8 @@ templ FixtureReviewResultPage(
    - @reviewTeamStats(fixture.HomeTeam, result, "home") - @reviewTeamStats(fixture.AwayTeam, result, "away") + @reviewTeamStats(fixture.HomeTeam, result, "home", fixture.Season, fixture.League) + @reviewTeamStats(fixture.AwayTeam, result, "away", fixture.Season, fixture.League)
    @@ -164,7 +175,7 @@ templ FixtureReviewResultPage( } } -templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) { +templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string, season *db.Season, league *db.League) { {{ // Collect unique players for this team across all periods // We'll show the period 3 (final/cumulative) stats @@ -197,7 +208,7 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) { } else { Away โ€” } - { team.Name } + @links.TeamNameLinkInSeason(team, season, league)
    @@ -218,10 +229,12 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) { for _, ps := range finalStats { - + - { ps.Username } - if ps.PlayerID == nil { + if ps.PlayerID != nil { + @links.PlayerLinkFromStats(*ps.PlayerID, ps.Username) + } else { + { ps.Username } ? } if ps.Stats.IsFreeAgent { diff --git a/internal/view/seasonsview/season_league_free_agents.templ b/internal/view/seasonsview/season_league_free_agents.templ index 7ff354b..b08291c 100644 --- a/internal/view/seasonsview/season_league_free_agents.templ +++ b/internal/view/seasonsview/season_league_free_agents.templ @@ -3,6 +3,7 @@ package seasonsview import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/permissions" import "git.haelnorr.com/h/oslstats/internal/contexts" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" import "fmt" templ SeasonLeagueFreeAgentsPage(season *db.Season, league *db.League, freeAgents []*db.SeasonLeagueFreeAgent, availablePlayers []*db.Player) { @@ -53,9 +54,9 @@ templ SeasonLeagueFreeAgents(season *db.Season, league *db.League, freeAgents [] for _, fa := range freeAgents { - + - { fa.Player.DisplayName() } + @links.PlayerLink(fa.Player) FREE AGENT diff --git a/internal/view/seasonsview/season_league_table.templ b/internal/view/seasonsview/season_league_table.templ index ae80916..560c507 100644 --- a/internal/view/seasonsview/season_league_table.templ +++ b/internal/view/seasonsview/season_league_table.templ @@ -1,15 +1,16 @@ package seasonsview import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" import "fmt" templ SeasonLeagueTablePage(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) { @SeasonLeagueLayout("table", season, league) { - @SeasonLeagueTable(leaderboard) + @SeasonLeagueTable(season, league, leaderboard) } } -templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) { +templ SeasonLeagueTable(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) { if len(leaderboard) == 0 {

    No teams in this league yet.

    @@ -43,7 +44,7 @@ templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) { for _, entry := range leaderboard { - @leaderboardRow(entry) + @leaderboardRow(entry, season, league) } @@ -52,7 +53,7 @@ templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) { } } -templ leaderboardRow(entry *db.LeaderboardEntry) { +templ leaderboardRow(entry *db.LeaderboardEntry, season *db.Season, league *db.League) { {{ r := entry.Record goalDiff := r.GoalsFor - r.GoalsAgainst @@ -68,15 +69,7 @@ templ leaderboardRow(entry *db.LeaderboardEntry) { { fmt.Sprint(entry.Position) } -
    - if entry.Team.Color != "" { - - } - { entry.Team.Name } -
    + @links.TeamLinkInSeason(entry.Team, season, league) { fmt.Sprint(r.Played) } diff --git a/internal/view/seasonsview/season_league_team_detail.templ b/internal/view/seasonsview/season_league_team_detail.templ index 3019d01..709ffa4 100644 --- a/internal/view/seasonsview/season_league_team_detail.templ +++ b/internal/view/seasonsview/season_league_team_detail.templ @@ -4,6 +4,7 @@ import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/permissions" import "git.haelnorr.com/h/oslstats/internal/contexts" import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" import "fmt" import "sort" import "time" @@ -154,7 +155,9 @@ templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) {
    if twr.Manager != nil {
    - { twr.Manager.Name } + + @links.PlayerLink(twr.Manager) + ★ Manager @@ -162,7 +165,7 @@ templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) { } for _, player := range rosterPlayers {
    - { player.Name } + @links.PlayerLink(player)
    }
    @@ -680,7 +683,9 @@ templ playerStatsSection(playerStats []*db.AggregatedPlayerStats) { for _, ps := range playerStats { - { ps.PlayerName } + + @links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName) + { fmt.Sprint(ps.GamesPlayed) } { fmt.Sprint(ps.PeriodsPlayed) } { fmt.Sprint(ps.Score) } diff --git a/internal/view/teamsview/detail_player_stats.templ b/internal/view/teamsview/detail_player_stats.templ index 085c7cb..ad7301f 100644 --- a/internal/view/teamsview/detail_player_stats.templ +++ b/internal/view/teamsview/detail_player_stats.templ @@ -1,6 +1,7 @@ package teamsview import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" import "fmt" import "sort" @@ -108,7 +109,9 @@ templ playerStatsTable(playerStats []*db.TeamAllTimePlayerStats, statType string { fmt.Sprint(i + 1) } - { ps.PlayerName } + + @links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName) + { fmt.Sprint(ps.SeasonsPlayed) } { fmt.Sprint(ps.PeriodsPlayed) } if statType == "goals" { From a4723144744c16d5aea261fdc7472ab9a2835eb1 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 21:05:55 +1100 Subject: [PATCH 10/33] removed "registered by" field for free agents --- internal/view/seasonsview/season_league_free_agents.templ | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/view/seasonsview/season_league_free_agents.templ b/internal/view/seasonsview/season_league_free_agents.templ index b08291c..18271e1 100644 --- a/internal/view/seasonsview/season_league_free_agents.templ +++ b/internal/view/seasonsview/season_league_free_agents.templ @@ -45,7 +45,6 @@ templ SeasonLeagueFreeAgents(season *db.Season, league *db.League, freeAgents [] Player - Registered By if canRemove { Actions } @@ -62,11 +61,6 @@ templ SeasonLeagueFreeAgents(season *db.Season, league *db.League, freeAgents [] - - if fa.RegisteredBy != nil { - { fa.RegisteredBy.Username } - } - if canRemove {
    Date: Fri, 6 Mar 2026 21:25:05 +1100 Subject: [PATCH 11/33] added stat leaderboards --- internal/db/fixture_result.go | 216 +++++++++++++++++ internal/embedfs/web/css/output.css | 10 + internal/handlers/season_league_stats.go | 23 +- .../seasonsview/season_league_stats.templ | 222 +++++++++++++++++- 4 files changed, 464 insertions(+), 7 deletions(-) diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go index 002daf1..7eed94a 100644 --- a/internal/db/fixture_result.go +++ b/internal/db/fixture_result.go @@ -437,6 +437,222 @@ func GetAggregatedPlayerStatsForTeam( return stats, nil } +// LeagueTopGoalScorer holds aggregated goal scoring stats for a player in a season-league. +type LeagueTopGoalScorer struct { + PlayerID int `bun:"player_id"` + PlayerName string `bun:"player_name"` + TeamID int `bun:"team_id"` + TeamName string `bun:"team_name"` + TeamColor string `bun:"team_color"` + Goals int `bun:"total_goals"` + PeriodsPlayed int `bun:"total_periods_played"` + Shots int `bun:"total_shots"` +} + +// GetTopGoalScorers returns the top goal scorers for a season-league, +// sorted by goals DESC, periods ASC, shots ASC. +// Stats are combined across all teams a player may have played on, +// and the player's current roster team is shown. +func GetTopGoalScorers( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, +) ([]*LeagueTopGoalScorer, error) { + if seasonID == 0 { + return nil, errors.New("seasonID not provided") + } + if leagueID == 0 { + return nil, errors.New("leagueID not provided") + } + + var stats []*LeagueTopGoalScorer + err := tx.NewRaw(` + SELECT + agg.player_id, + agg.player_name, + COALESCE(tr.team_id, 0) AS team_id, + COALESCE(t.name, '') AS team_name, + COALESCE(t.color, '') AS team_color, + agg.total_goals, + agg.total_periods_played, + agg.total_shots + FROM ( + SELECT + frps.player_id AS player_id, + COALESCE(p.name, frps.player_username) AS player_name, + COALESCE(SUM(frps.goals), 0) AS total_goals, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, + COALESCE(SUM(frps.shots), 0) AS total_shots + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + JOIN fixtures f ON f.id = fr.fixture_id + LEFT JOIN players p ON p.id = frps.player_id + WHERE fr.finalized = true + AND f.season_id = ? + AND f.league_id = ? + AND frps.period_num = 3 + AND frps.player_id IS NOT NULL + GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) + ORDER BY total_goals DESC, total_periods_played ASC, total_shots ASC + LIMIT 10 + ) agg + LEFT JOIN team_rosters tr + ON tr.player_id = agg.player_id + AND tr.season_id = ? + AND tr.league_id = ? + LEFT JOIN teams t ON t.id = tr.team_id + ORDER BY agg.total_goals DESC, agg.total_periods_played ASC, agg.total_shots ASC + `, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + +// LeagueTopAssister holds aggregated assist stats for a player in a season-league. +type LeagueTopAssister struct { + PlayerID int `bun:"player_id"` + PlayerName string `bun:"player_name"` + TeamID int `bun:"team_id"` + TeamName string `bun:"team_name"` + TeamColor string `bun:"team_color"` + Assists int `bun:"total_assists"` + PeriodsPlayed int `bun:"total_periods_played"` + PrimaryAssists int `bun:"total_primary_assists"` +} + +// GetTopAssisters returns the top assisters for a season-league, +// sorted by assists DESC, periods ASC, primary assists DESC. +// Stats are combined across all teams a player may have played on, +// and the player's current roster team is shown. +func GetTopAssisters( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, +) ([]*LeagueTopAssister, error) { + if seasonID == 0 { + return nil, errors.New("seasonID not provided") + } + if leagueID == 0 { + return nil, errors.New("leagueID not provided") + } + + var stats []*LeagueTopAssister + err := tx.NewRaw(` + SELECT + agg.player_id, + agg.player_name, + COALESCE(tr.team_id, 0) AS team_id, + COALESCE(t.name, '') AS team_name, + COALESCE(t.color, '') AS team_color, + agg.total_assists, + agg.total_periods_played, + agg.total_primary_assists + FROM ( + SELECT + frps.player_id AS player_id, + COALESCE(p.name, frps.player_username) AS player_name, + COALESCE(SUM(frps.assists), 0) AS total_assists, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, + COALESCE(SUM(frps.primary_assists), 0) AS total_primary_assists + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + JOIN fixtures f ON f.id = fr.fixture_id + LEFT JOIN players p ON p.id = frps.player_id + WHERE fr.finalized = true + AND f.season_id = ? + AND f.league_id = ? + AND frps.period_num = 3 + AND frps.player_id IS NOT NULL + GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) + ORDER BY total_assists DESC, total_periods_played ASC, total_primary_assists DESC + LIMIT 10 + ) agg + LEFT JOIN team_rosters tr + ON tr.player_id = agg.player_id + AND tr.season_id = ? + AND tr.league_id = ? + LEFT JOIN teams t ON t.id = tr.team_id + ORDER BY agg.total_assists DESC, agg.total_periods_played ASC, agg.total_primary_assists DESC + `, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + +// LeagueTopSaver holds aggregated save stats for a player in a season-league. +type LeagueTopSaver struct { + PlayerID int `bun:"player_id"` + PlayerName string `bun:"player_name"` + TeamID int `bun:"team_id"` + TeamName string `bun:"team_name"` + TeamColor string `bun:"team_color"` + Saves int `bun:"total_saves"` + PeriodsPlayed int `bun:"total_periods_played"` + Blocks int `bun:"total_blocks"` +} + +// GetTopSavers returns the top savers for a season-league, +// sorted by saves DESC, periods ASC, blocks DESC. +// Stats are combined across all teams a player may have played on, +// and the player's current roster team is shown. +func GetTopSavers( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, +) ([]*LeagueTopSaver, error) { + if seasonID == 0 { + return nil, errors.New("seasonID not provided") + } + if leagueID == 0 { + return nil, errors.New("leagueID not provided") + } + + var stats []*LeagueTopSaver + err := tx.NewRaw(` + SELECT + agg.player_id, + agg.player_name, + COALESCE(tr.team_id, 0) AS team_id, + COALESCE(t.name, '') AS team_name, + COALESCE(t.color, '') AS team_color, + agg.total_saves, + agg.total_periods_played, + agg.total_blocks + FROM ( + SELECT + frps.player_id AS player_id, + COALESCE(p.name, frps.player_username) AS player_name, + COALESCE(SUM(frps.saves), 0) AS total_saves, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, + COALESCE(SUM(frps.blocks), 0) AS total_blocks + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + JOIN fixtures f ON f.id = fr.fixture_id + LEFT JOIN players p ON p.id = frps.player_id + WHERE fr.finalized = true + AND f.season_id = ? + AND f.league_id = ? + AND frps.period_num = 3 + AND frps.player_id IS NOT NULL + GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) + ORDER BY total_saves DESC, total_periods_played ASC, total_blocks DESC + LIMIT 10 + ) agg + LEFT JOIN team_rosters tr + ON tr.player_id = agg.player_id + AND tr.season_id = ? + AND tr.league_id = ? + LEFT JOIN teams t ON t.id = tr.team_id + ORDER BY agg.total_saves DESC, agg.total_periods_played ASC, agg.total_blocks DESC + `, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + // TeamRecord holds win/loss/draw record and goal totals for a team. type TeamRecord struct { Played int diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 5d03bb1..8d568ff 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -2189,11 +2189,21 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } + .lg\:flex-row { + @media (width >= 64rem) { + flex-direction: row; + } + } .lg\:items-end { @media (width >= 64rem) { align-items: flex-end; } } + .lg\:items-start { + @media (width >= 64rem) { + align-items: flex-start; + } + } .lg\:justify-between { @media (width >= 64rem) { justify-content: space-between; diff --git a/internal/handlers/season_league_stats.go b/internal/handlers/season_league_stats.go index 96a64e0..e8f6258 100644 --- a/internal/handlers/season_league_stats.go +++ b/internal/handlers/season_league_stats.go @@ -22,6 +22,9 @@ func SeasonLeagueStatsPage( leagueStr := r.PathValue("league_short_name") var sl *db.SeasonLeague + var topGoals []*db.LeagueTopGoalScorer + var topAssists []*db.LeagueTopAssister + var topSaves []*db.LeagueTopSaver if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error @@ -33,15 +36,31 @@ func SeasonLeagueStatsPage( } return false, errors.Wrap(err, "db.GetSeasonLeague") } + + topGoals, err = db.GetTopGoalScorers(ctx, tx, sl.SeasonID, sl.LeagueID) + if err != nil { + return false, errors.Wrap(err, "db.GetTopGoalScorers") + } + + topAssists, err = db.GetTopAssisters(ctx, tx, sl.SeasonID, sl.LeagueID) + if err != nil { + return false, errors.Wrap(err, "db.GetTopAssisters") + } + + topSaves, err = db.GetTopSavers(ctx, tx, sl.SeasonID, sl.LeagueID) + if err != nil { + return false, errors.Wrap(err, "db.GetTopSavers") + } + return true, nil }); !ok { return } if r.Method == "GET" { - renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League), s, r, w) + renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League, topGoals, topAssists, topSaves), s, r, w) } else { - renderSafely(seasonsview.SeasonLeagueStats(), s, r, w) + renderSafely(seasonsview.SeasonLeagueStats(sl.Season, sl.League, topGoals, topAssists, topSaves), s, r, w) } }) } diff --git a/internal/view/seasonsview/season_league_stats.templ b/internal/view/seasonsview/season_league_stats.templ index b7622ff..a39dfc0 100644 --- a/internal/view/seasonsview/season_league_stats.templ +++ b/internal/view/seasonsview/season_league_stats.templ @@ -1,15 +1,227 @@ package seasonsview import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" -templ SeasonLeagueStatsPage(season *db.Season, league *db.League) { +templ SeasonLeagueStatsPage( + season *db.Season, + league *db.League, + topGoals []*db.LeagueTopGoalScorer, + topAssists []*db.LeagueTopAssister, + topSaves []*db.LeagueTopSaver, +) { @SeasonLeagueLayout("stats", season, league) { - @SeasonLeagueStats() + @SeasonLeagueStats(season, league, topGoals, topAssists, topSaves) } } -templ SeasonLeagueStats() { -
    -

    Coming Soon...

    +templ SeasonLeagueStats( + season *db.Season, + league *db.League, + topGoals []*db.LeagueTopGoalScorer, + topAssists []*db.LeagueTopAssister, + topSaves []*db.LeagueTopSaver, +) { + if len(topGoals) == 0 && len(topAssists) == 0 && len(topSaves) == 0 { +
    +

    No stats available yet.

    +

    Player statistics will appear here once games are finalized.

    +
    + } else { + +
    + +
    + @topGoalScorersTable(season, league, topGoals) + @topAssistersTable(season, league, topAssists) +
    + + @topSaversTable(season, league, topSaves) +
    + } +} + +templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.LeagueTopGoalScorer) { +
    +
    +

    + Top Goal Scorers +

    +
    + +
    + Sort: + G ↓ + PP ↑ + SH ↑ +
    + if len(goals) == 0 { +
    +

    No goal data available yet.

    +
    + } else { +
    + + + + + + + + + + + + + for i, gs := range goals { + + + + + + + + + } + +
    #PlayerTeamGPPSH
    + { fmt.Sprint(i + 1) } + + @links.PlayerLinkFromStats(gs.PlayerID, gs.PlayerName) + + @teamColorName(gs.TeamID, gs.TeamName, gs.TeamColor, season, league) + { fmt.Sprint(gs.Goals) }{ fmt.Sprint(gs.PeriodsPlayed) }{ fmt.Sprint(gs.Shots) }
    +
    + }
    } + +templ topAssistersTable(season *db.Season, league *db.League, assists []*db.LeagueTopAssister) { +
    +
    +

    + Top Assisters +

    +
    + +
    + Sort: + A ↓ + PP ↑ + PA ↓ +
    + if len(assists) == 0 { +
    +

    No assist data available yet.

    +
    + } else { +
    + + + + + + + + + + + + + for i, as := range assists { + + + + + + + + + } + +
    #PlayerTeamAPPPA
    + { fmt.Sprint(i + 1) } + + @links.PlayerLinkFromStats(as.PlayerID, as.PlayerName) + + @teamColorName(as.TeamID, as.TeamName, as.TeamColor, season, league) + { fmt.Sprint(as.Assists) }{ fmt.Sprint(as.PeriodsPlayed) }{ fmt.Sprint(as.PrimaryAssists) }
    +
    + } +
    +} + +templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTopSaver) { +
    +
    +

    + Top Saves +

    +
    + +
    + Sort: + SV ↓ + PP ↑ + BLK ↓ +
    + if len(saves) == 0 { +
    +

    No save data available yet.

    +
    + } else { +
    + + + + + + + + + + + + + for i, sv := range saves { + + + + + + + + + } + +
    #PlayerTeamSVPPBLK
    + { fmt.Sprint(i + 1) } + + @links.PlayerLinkFromStats(sv.PlayerID, sv.PlayerName) + + @teamColorName(sv.TeamID, sv.TeamName, sv.TeamColor, season, league) + { fmt.Sprint(sv.Saves) }{ fmt.Sprint(sv.PeriodsPlayed) }{ fmt.Sprint(sv.Blocks) }
    +
    + } +
    +} + +templ teamColorName(teamID int, teamName string, teamColor string, season *db.Season, league *db.League) { + if teamID > 0 && teamName != "" { + + if teamColor != "" { + + } + { teamName } + + } else { + โ€” + } +} From b96aeef32e5e3473bef94c576861cfd36e56e5a2 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 21:37:02 +1100 Subject: [PATCH 12/33] added league stats --- internal/db/fixture_result.go | 93 ++++++ internal/embedfs/web/css/output.css | 10 + internal/embedfs/web/js/sortable-table.js | 36 ++ internal/handlers/season_league_stats.go | 10 +- .../seasonsview/season_league_stats.templ | 310 ++++++++++++------ 5 files changed, 349 insertions(+), 110 deletions(-) create mode 100644 internal/embedfs/web/js/sortable-table.js diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go index 7eed94a..5a14eeb 100644 --- a/internal/db/fixture_result.go +++ b/internal/db/fixture_result.go @@ -437,6 +437,99 @@ func GetAggregatedPlayerStatsForTeam( return stats, nil } +// LeaguePlayerStats holds all aggregated stats for a player in a season-league. +type LeaguePlayerStats struct { + PlayerID int `bun:"player_id"` + PlayerName string `bun:"player_name"` + TeamID int `bun:"team_id"` + TeamName string `bun:"team_name"` + TeamColor string `bun:"team_color"` + GamesPlayed int `bun:"games_played"` + PeriodsPlayed int `bun:"total_periods_played"` + Goals int `bun:"total_goals"` + Assists int `bun:"total_assists"` + PrimaryAssists int `bun:"total_primary_assists"` + SecondaryAssists int `bun:"total_secondary_assists"` + Saves int `bun:"total_saves"` + Shots int `bun:"total_shots"` + Blocks int `bun:"total_blocks"` + Passes int `bun:"total_passes"` + Score int `bun:"total_score"` +} + +// GetAllLeaguePlayerStats returns aggregated stats for all players in a season-league. +// Stats are combined across all teams a player may have played on, +// and the player's current roster team is shown. +func GetAllLeaguePlayerStats( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, +) ([]*LeaguePlayerStats, error) { + if seasonID == 0 { + return nil, errors.New("seasonID not provided") + } + if leagueID == 0 { + return nil, errors.New("leagueID not provided") + } + + var stats []*LeaguePlayerStats + err := tx.NewRaw(` + SELECT + agg.player_id, + agg.player_name, + COALESCE(tr.team_id, 0) AS team_id, + COALESCE(t.name, '') AS team_name, + COALESCE(t.color, '') AS team_color, + agg.games_played, + agg.total_periods_played, + agg.total_goals, + agg.total_assists, + agg.total_primary_assists, + agg.total_secondary_assists, + agg.total_saves, + agg.total_shots, + agg.total_blocks, + agg.total_passes, + agg.total_score + FROM ( + SELECT + frps.player_id AS player_id, + COALESCE(p.name, frps.player_username) AS player_name, + COUNT(DISTINCT frps.fixture_result_id) AS games_played, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, + COALESCE(SUM(frps.goals), 0) AS total_goals, + COALESCE(SUM(frps.assists), 0) AS total_assists, + COALESCE(SUM(frps.primary_assists), 0) AS total_primary_assists, + COALESCE(SUM(frps.secondary_assists), 0) AS total_secondary_assists, + COALESCE(SUM(frps.saves), 0) AS total_saves, + COALESCE(SUM(frps.shots), 0) AS total_shots, + COALESCE(SUM(frps.blocks), 0) AS total_blocks, + COALESCE(SUM(frps.passes), 0) AS total_passes, + COALESCE(SUM(frps.score), 0) AS total_score + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + JOIN fixtures f ON f.id = fr.fixture_id + LEFT JOIN players p ON p.id = frps.player_id + WHERE fr.finalized = true + AND f.season_id = ? + AND f.league_id = ? + AND frps.period_num = 3 + AND frps.player_id IS NOT NULL + GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) + ) agg + LEFT JOIN team_rosters tr + ON tr.player_id = agg.player_id + AND tr.season_id = ? + AND tr.league_id = ? + LEFT JOIN teams t ON t.id = tr.team_id + ORDER BY agg.total_score DESC + `, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + // LeagueTopGoalScorer holds aggregated goal scoring stats for a player in a season-league. type LeagueTopGoalScorer struct { PlayerID int `bun:"player_id"` diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 8d568ff..ce19615 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -705,6 +705,9 @@ .justify-end { justify-content: flex-end; } + .gap-0\.5 { + gap: calc(var(--spacing) * 0.5); + } .gap-1 { gap: calc(var(--spacing) * 1); } @@ -768,6 +771,13 @@ margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); } } + .space-y-8 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse))); + } + } .gap-x-2 { column-gap: calc(var(--spacing) * 2); } diff --git a/internal/embedfs/web/js/sortable-table.js b/internal/embedfs/web/js/sortable-table.js new file mode 100644 index 0000000..f951d2d --- /dev/null +++ b/internal/embedfs/web/js/sortable-table.js @@ -0,0 +1,36 @@ +function sortableTable(initField, initDir) { + return { + sortField: initField || "score", + sortDir: initDir || "desc", + + sort(field) { + if (this.sortField === field) { + this.sortDir = this.sortDir === "asc" ? "desc" : "asc"; + } else { + this.sortField = field; + this.sortDir = "desc"; + } + this.reorder(); + }, + + reorder() { + const tbody = this.$refs.tbody; + if (!tbody) return; + const rows = Array.from(tbody.querySelectorAll("tr")); + const field = this.sortField; + const dir = this.sortDir === "asc" ? 1 : -1; + + rows.sort((a, b) => { + const aVal = parseFloat(a.dataset[field]) || 0; + const bVal = parseFloat(b.dataset[field]) || 0; + if (aVal !== bVal) return (aVal - bVal) * dir; + // Tiebreak: alphabetical by player name + const aName = (a.dataset.name || "").toLowerCase(); + const bName = (b.dataset.name || "").toLowerCase(); + return aName < bName ? -1 : aName > bName ? 1 : 0; + }); + + rows.forEach((row) => tbody.appendChild(row)); + }, + }; +} diff --git a/internal/handlers/season_league_stats.go b/internal/handlers/season_league_stats.go index e8f6258..18add75 100644 --- a/internal/handlers/season_league_stats.go +++ b/internal/handlers/season_league_stats.go @@ -25,6 +25,7 @@ func SeasonLeagueStatsPage( 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 @@ -52,15 +53,20 @@ func SeasonLeagueStatsPage( return false, errors.Wrap(err, "db.GetTopSavers") } + allStats, err = db.GetAllLeaguePlayerStats(ctx, tx, sl.SeasonID, sl.LeagueID) + if err != nil { + return false, errors.Wrap(err, "db.GetAllLeaguePlayerStats") + } + return true, nil }); !ok { return } if r.Method == "GET" { - renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League, topGoals, topAssists, topSaves), s, r, w) + renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League, topGoals, topAssists, topSaves, allStats), s, r, w) } else { - renderSafely(seasonsview.SeasonLeagueStats(sl.Season, sl.League, topGoals, topAssists, topSaves), s, r, w) + renderSafely(seasonsview.SeasonLeagueStats(sl.Season, sl.League, topGoals, topAssists, topSaves, allStats), s, r, w) } }) } diff --git a/internal/view/seasonsview/season_league_stats.templ b/internal/view/seasonsview/season_league_stats.templ index a39dfc0..6bc7de1 100644 --- a/internal/view/seasonsview/season_league_stats.templ +++ b/internal/view/seasonsview/season_league_stats.templ @@ -10,9 +10,10 @@ templ SeasonLeagueStatsPage( topGoals []*db.LeagueTopGoalScorer, topAssists []*db.LeagueTopAssister, topSaves []*db.LeagueTopSaver, + allStats []*db.LeaguePlayerStats, ) { @SeasonLeagueLayout("stats", season, league) { - @SeasonLeagueStats(season, league, topGoals, topAssists, topSaves) + @SeasonLeagueStats(season, league, topGoals, topAssists, topSaves, allStats) } } @@ -22,28 +23,45 @@ templ SeasonLeagueStats( topGoals []*db.LeagueTopGoalScorer, topAssists []*db.LeagueTopAssister, topSaves []*db.LeagueTopSaver, + allStats []*db.LeaguePlayerStats, ) { - if len(topGoals) == 0 && len(topAssists) == 0 && len(topSaves) == 0 { + if len(topGoals) == 0 && len(topAssists) == 0 && len(topSaves) == 0 && len(allStats) == 0 {

    No stats available yet.

    Player statistics will appear here once games are finalized.

    } else { - -
    - -
    - @topGoalScorersTable(season, league, topGoals) - @topAssistersTable(season, league, topAssists) -
    - - @topSaversTable(season, league, topSaves) +
    + + if len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 { +
    +

    Trophy Leaders

    + +
    + +
    + @topGoalScorersTable(season, league, topGoals) + @topAssistersTable(season, league, topAssists) +
    + + @topSaversTable(season, league, topSaves) +
    +
    + } + + if len(allStats) > 0 { +
    +

    All Stats

    + @allStatsTable(season, league, allStats) +
    + }
    } + } templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.LeagueTopGoalScorer) { -
    +

    Top Goal Scorers @@ -61,44 +79,42 @@ templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.Leag

    No goal data available yet.

    } else { -
    - - - - - - - - - +
    #PlayerTeamGPPSH
    + + + + + + + + + + + + for i, gs := range goals { + + + + + + + - - - for i, gs := range goals { - - - - - - - - - } - -
    #PlayerTeamGPPSH
    + { fmt.Sprint(i + 1) } + + @links.PlayerLinkFromStats(gs.PlayerID, gs.PlayerName) + + @teamColorName(gs.TeamID, gs.TeamName, gs.TeamColor, season, league) + { fmt.Sprint(gs.Goals) }{ fmt.Sprint(gs.PeriodsPlayed) }{ fmt.Sprint(gs.Shots) }
    - { fmt.Sprint(i + 1) } - - @links.PlayerLinkFromStats(gs.PlayerID, gs.PlayerName) - - @teamColorName(gs.TeamID, gs.TeamName, gs.TeamColor, season, league) - { fmt.Sprint(gs.Goals) }{ fmt.Sprint(gs.PeriodsPlayed) }{ fmt.Sprint(gs.Shots) }
    -
    + } + + }
    } templ topAssistersTable(season *db.Season, league *db.League, assists []*db.LeagueTopAssister) { -
    +

    Top Assisters @@ -116,44 +132,42 @@ templ topAssistersTable(season *db.Season, league *db.League, assists []*db.Leag

    No assist data available yet.

    } else { -
    - - - - - - - - - +
    #PlayerTeamAPPPA
    + + + + + + + + + + + + for i, as := range assists { + + + + + + + - - - for i, as := range assists { - - - - - - - - - } - -
    #PlayerTeamAPPPA
    + { fmt.Sprint(i + 1) } + + @links.PlayerLinkFromStats(as.PlayerID, as.PlayerName) + + @teamColorName(as.TeamID, as.TeamName, as.TeamColor, season, league) + { fmt.Sprint(as.Assists) }{ fmt.Sprint(as.PeriodsPlayed) }{ fmt.Sprint(as.PrimaryAssists) }
    - { fmt.Sprint(i + 1) } - - @links.PlayerLinkFromStats(as.PlayerID, as.PlayerName) - - @teamColorName(as.TeamID, as.TeamName, as.TeamColor, season, league) - { fmt.Sprint(as.Assists) }{ fmt.Sprint(as.PeriodsPlayed) }{ fmt.Sprint(as.PrimaryAssists) }
    -
    + } + + }
    } templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTopSaver) { -
    +

    Top Saves @@ -171,42 +185,122 @@ templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTop

    No save data available yet.

    } else { -
    - - - - - - - - - +
    #PlayerTeamSVPPBLK
    + + + + + + + + + + + + for i, sv := range saves { + + + + + + + - - - for i, sv := range saves { - - - - - - - - - } - -
    #PlayerTeamSVPPBLK
    + { fmt.Sprint(i + 1) } + + @links.PlayerLinkFromStats(sv.PlayerID, sv.PlayerName) + + @teamColorName(sv.TeamID, sv.TeamName, sv.TeamColor, season, league) + { fmt.Sprint(sv.Saves) }{ fmt.Sprint(sv.PeriodsPlayed) }{ fmt.Sprint(sv.Blocks) }
    - { fmt.Sprint(i + 1) } - - @links.PlayerLinkFromStats(sv.PlayerID, sv.PlayerName) - - @teamColorName(sv.TeamID, sv.TeamName, sv.TeamColor, season, league) - { fmt.Sprint(sv.Saves) }{ fmt.Sprint(sv.PeriodsPlayed) }{ fmt.Sprint(sv.Blocks) }
    -
    + } + + }
    } +templ allStatsTable(season *db.Season, league *db.League, allStats []*db.LeaguePlayerStats) { +
    +
    + + + + + + @sortableCol("gp", "GP", "Games Played") + @sortableCol("pp", "PP", "Periods Played") + @sortableCol("score", "SC", "Score") + @sortableCol("goals", "G", "Goals") + @sortableCol("assists", "A", "Assists") + @sortableCol("pa", "PA", "Primary Assists") + @sortableCol("sa", "SA", "Secondary Assists") + @sortableCol("saves", "SV", "Saves") + @sortableCol("shots", "SH", "Shots") + @sortableCol("blocks", "BLK", "Blocks") + @sortableCol("passes", "PAS", "Passes") + + + + for _, ps := range allStats { + + + + + + + + + + + + + + + + } + +
    PlayerTeam
    + @links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName) + + @teamColorName(ps.TeamID, ps.TeamName, ps.TeamColor, season, league) + { fmt.Sprint(ps.GamesPlayed) }{ fmt.Sprint(ps.PeriodsPlayed) }{ fmt.Sprint(ps.Score) }{ fmt.Sprint(ps.Goals) }{ fmt.Sprint(ps.Assists) }{ fmt.Sprint(ps.PrimaryAssists) }{ fmt.Sprint(ps.SecondaryAssists) }{ fmt.Sprint(ps.Saves) }{ fmt.Sprint(ps.Shots) }{ fmt.Sprint(ps.Blocks) }{ fmt.Sprint(ps.Passes) }
    +
    +
    +} + +templ sortableCol(field string, label string, title string) { + + + { label } + + + +} + templ teamColorName(teamID int, teamName string, teamColor string, season *db.Season, league *db.League) { if teamID > 0 && teamName != "" { } - { teamName } + { teamName } } else { โ€” From b957df8d32764a14b32e6635ab58ae16ca8c8486 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 21:40:18 +1100 Subject: [PATCH 13/33] fixed stat sorting --- internal/view/seasonsview/season_league_stats.templ | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/view/seasonsview/season_league_stats.templ b/internal/view/seasonsview/season_league_stats.templ index 6bc7de1..16fad83 100644 --- a/internal/view/seasonsview/season_league_stats.templ +++ b/internal/view/seasonsview/season_league_stats.templ @@ -25,6 +25,7 @@ templ SeasonLeagueStats( topSaves []*db.LeagueTopSaver, allStats []*db.LeaguePlayerStats, ) { + if len(topGoals) == 0 && len(topAssists) == 0 && len(topSaves) == 0 && len(allStats) == 0 {

    No stats available yet.

    @@ -57,7 +58,6 @@ templ SeasonLeagueStats( }
    } - } templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.LeagueTopGoalScorer) { From f5c9e70edfb2e280e310260b26e059068c228652 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 22:08:41 +1100 Subject: [PATCH 14/33] added match preview and analysis --- internal/db/match_preview.go | 254 ++++++++ internal/embedfs/web/css/output.css | 138 ++++ internal/handlers/fixture_detail.go | 11 + .../view/seasonsview/fixture_detail.templ | 32 +- .../seasonsview/fixture_match_analysis.templ | 611 ++++++++++++++++++ .../seasonsview/fixture_match_preview.templ | 435 +++++++++++++ 6 files changed, 1473 insertions(+), 8 deletions(-) create mode 100644 internal/db/match_preview.go create mode 100644 internal/view/seasonsview/fixture_match_analysis.templ create mode 100644 internal/view/seasonsview/fixture_match_preview.templ diff --git a/internal/db/match_preview.go b/internal/db/match_preview.go new file mode 100644 index 0000000..beb2c5c --- /dev/null +++ b/internal/db/match_preview.go @@ -0,0 +1,254 @@ +package db + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// Game outcome type constants. +const ( + OutcomeWin = "W" + OutcomeLoss = "L" + OutcomeOTWin = "OTW" + OutcomeOTLoss = "OTL" + OutcomeDraw = "D" + OutcomeForfeit = "F" +) + +// GameOutcome represents the result of a single game from a team's perspective. +type GameOutcome struct { + Type string // One of Outcome* constants: "W", "L", "OTW", "OTL", "D", "F" + Opponent *Team // The opposing team (may be nil if relation not loaded) + Score string // e.g. "3-1" or "" for forfeits + IsForfeit bool // Whether this game was decided by forfeit + Fixture *Fixture // The fixture itself +} + +// MatchPreviewData holds all computed data needed for the match preview tab. +type MatchPreviewData struct { + HomeRecord *TeamRecord + AwayRecord *TeamRecord + HomePosition int + AwayPosition int + TotalTeams int + HomeRecentGames []*GameOutcome + AwayRecentGames []*GameOutcome +} + +// ComputeRecentGames calculates the last N game outcomes for a given team. +// Fixtures should be all allocated fixtures for the season+league. +// Results should be finalized results mapped by fixture ID. +// Schedules should be accepted schedules mapped by fixture ID (for ordering by scheduled time). +// The returned outcomes are in chronological order (oldest first, newest last). +func ComputeRecentGames( + teamID int, + fixtures []*Fixture, + resultMap map[int]*FixtureResult, + scheduleMap map[int]*FixtureSchedule, + limit int, +) []*GameOutcome { + // Collect fixtures involving this team that have finalized results + type fixtureWithTime struct { + fixture *Fixture + result *FixtureResult + time time.Time + } + var played []fixtureWithTime + + for _, f := range fixtures { + if f.HomeTeamID != teamID && f.AwayTeamID != teamID { + continue + } + res, ok := resultMap[f.ID] + if !ok { + continue + } + // Use schedule time for ordering, fall back to result creation time + t := time.Unix(res.CreatedAt, 0) + if scheduleMap != nil { + if sched, ok := scheduleMap[f.ID]; ok && sched.ScheduledTime != nil { + t = *sched.ScheduledTime + } + } + played = append(played, fixtureWithTime{fixture: f, result: res, time: t}) + } + + // Sort by time descending (most recent first) + sort.Slice(played, func(i, j int) bool { + return played[i].time.After(played[j].time) + }) + + // Take only the most recent N + if len(played) > limit { + played = played[:limit] + } + + // Reverse to chronological order (oldest first) + for i, j := 0, len(played)-1; i < j; i, j = i+1, j-1 { + played[i], played[j] = played[j], played[i] + } + + // Build outcome list + outcomes := make([]*GameOutcome, len(played)) + for i, p := range played { + outcomes[i] = buildGameOutcome(teamID, p.fixture, p.result) + } + return outcomes +} + +// buildGameOutcome determines the outcome type for a single game from a team's perspective. +// Note: fixtures must have their HomeTeam and AwayTeam relations loaded. +func buildGameOutcome(teamID int, fixture *Fixture, result *FixtureResult) *GameOutcome { + isHome := fixture.HomeTeamID == teamID + var opponent *Team + if isHome { + opponent = fixture.AwayTeam // may be nil if relation not loaded + } else { + opponent = fixture.HomeTeam // may be nil if relation not loaded + } + + outcome := &GameOutcome{ + Opponent: opponent, + Fixture: fixture, + } + + // Handle forfeits + if result.IsForfeit { + outcome.IsForfeit = true + outcome.Type = OutcomeForfeit + if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeMutual { + outcome.Type = OutcomeOTLoss // mutual forfeit counts as OT loss for both + } else if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeOutright { + thisSide := "away" + if isHome { + thisSide = "home" + } + if result.ForfeitTeam != nil && *result.ForfeitTeam == thisSide { + outcome.Type = OutcomeLoss // this team forfeited + } else { + outcome.Type = OutcomeWin // opponent forfeited + } + } + return outcome + } + + // Normal match - build score string from this team's perspective + if isHome { + outcome.Score = fmt.Sprintf("%d-%d", result.HomeScore, result.AwayScore) + } else { + outcome.Score = fmt.Sprintf("%d-%d", result.AwayScore, result.HomeScore) + } + + won := (isHome && result.Winner == "home") || (!isHome && result.Winner == "away") + lost := (isHome && result.Winner == "away") || (!isHome && result.Winner == "home") + isOT := strings.EqualFold(result.EndReason, "Overtime") + + switch { + case won && isOT: + outcome.Type = OutcomeOTWin + case won: + outcome.Type = OutcomeWin + case lost && isOT: + outcome.Type = OutcomeOTLoss + case lost: + outcome.Type = OutcomeLoss + default: + outcome.Type = OutcomeDraw + } + + return outcome +} + +// GetTeamsForSeasonLeague returns all teams participating in a given season+league. +func GetTeamsForSeasonLeague(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Team, error) { + var teams []*Team + err := tx.NewSelect(). + Model(&teams). + Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id"). + Where("tp.season_id = ? AND tp.league_id = ?", seasonID, leagueID). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect") + } + return teams, nil +} + +// ComputeMatchPreview fetches all data needed for the match preview tab: +// team standings, positions, and recent game outcomes for both teams. +func ComputeMatchPreview( + ctx context.Context, + tx bun.Tx, + fixture *Fixture, +) (*MatchPreviewData, error) { + if fixture == nil { + return nil, errors.New("fixture cannot be nil") + } + + // Get all teams in this season+league + allTeams, err := GetTeamsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID) + if err != nil { + return nil, errors.Wrap(err, "GetTeamsForSeasonLeague") + } + + // Get all allocated fixtures for the season+league + allFixtures, err := GetAllocatedFixtures(ctx, tx, fixture.SeasonID, fixture.LeagueID) + if err != nil { + return nil, errors.Wrap(err, "GetAllocatedFixtures") + } + + // Get finalized results + allFixtureIDs := make([]int, len(allFixtures)) + for i, f := range allFixtures { + allFixtureIDs[i] = f.ID + } + allResultMap, err := GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs) + if err != nil { + return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures") + } + + // Get accepted schedules for ordering recent games + allScheduleMap, err := GetAcceptedSchedulesForFixtures(ctx, tx, allFixtureIDs) + if err != nil { + return nil, errors.Wrap(err, "GetAcceptedSchedulesForFixtures") + } + + // Compute leaderboard + leaderboard := ComputeLeaderboard(allTeams, allFixtures, allResultMap) + + // Extract positions and records for both teams + preview := &MatchPreviewData{ + TotalTeams: len(leaderboard), + } + for _, entry := range leaderboard { + if entry.Team.ID == fixture.HomeTeamID { + preview.HomePosition = entry.Position + preview.HomeRecord = entry.Record + } + if entry.Team.ID == fixture.AwayTeamID { + preview.AwayPosition = entry.Position + preview.AwayRecord = entry.Record + } + } + if preview.HomeRecord == nil { + preview.HomeRecord = &TeamRecord{} + } + if preview.AwayRecord == nil { + preview.AwayRecord = &TeamRecord{} + } + + // Compute recent games (last 5) for each team + preview.HomeRecentGames = ComputeRecentGames( + fixture.HomeTeamID, allFixtures, allResultMap, allScheduleMap, 5, + ) + preview.AwayRecentGames = ComputeRecentGames( + fixture.AwayTeamID, allFixtures, allResultMap, allScheduleMap, 5, + ) + + return preview, nil +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index ce19615..b9f0c88 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -35,6 +35,8 @@ --text-3xl--line-height: calc(2.25 / 1.875); --text-4xl: 2.25rem; --text-4xl--line-height: calc(2.5 / 2.25); + --text-5xl: 3rem; + --text-5xl--line-height: 1; --text-6xl: 3.75rem; --text-6xl--line-height: 1; --text-9xl: 8rem; @@ -47,6 +49,7 @@ --tracking-tight: -0.025em; --tracking-wider: 0.05em; --leading-relaxed: 1.625; + --radius-md: 0.375rem; --radius-lg: 0.5rem; --radius-xl: 0.75rem; --ease-in: cubic-bezier(0.4, 0, 1, 1); @@ -450,6 +453,9 @@ .h-3 { height: calc(var(--spacing) * 3); } + .h-3\.5 { + height: calc(var(--spacing) * 3.5); + } .h-4 { height: calc(var(--spacing) * 4); } @@ -459,9 +465,15 @@ .h-6 { height: calc(var(--spacing) * 6); } + .h-9 { + height: calc(var(--spacing) * 9); + } .h-12 { height: calc(var(--spacing) * 12); } + .h-14 { + height: calc(var(--spacing) * 14); + } .h-16 { height: calc(var(--spacing) * 16); } @@ -510,6 +522,9 @@ .w-3 { width: calc(var(--spacing) * 3); } + .w-3\.5 { + width: calc(var(--spacing) * 3.5); + } .w-4 { width: calc(var(--spacing) * 4); } @@ -519,18 +534,30 @@ .w-6 { width: calc(var(--spacing) * 6); } + .w-8 { + width: calc(var(--spacing) * 8); + } + .w-9 { + width: calc(var(--spacing) * 9); + } .w-10 { width: calc(var(--spacing) * 10); } .w-12 { width: calc(var(--spacing) * 12); } + .w-14 { + width: calc(var(--spacing) * 14); + } .w-20 { width: calc(var(--spacing) * 20); } .w-26 { width: calc(var(--spacing) * 26); } + .w-28 { + width: calc(var(--spacing) * 28); + } .w-48 { width: calc(var(--spacing) * 48); } @@ -636,6 +663,9 @@ .animate-spin { animation: var(--animate-spin); } + .cursor-default { + cursor: default; + } .cursor-grab { cursor: grab; } @@ -729,6 +759,13 @@ .gap-8 { gap: calc(var(--spacing) * 8); } + .space-y-0 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-0\.5 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -743,6 +780,13 @@ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } + .space-y-1\.5 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-2 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -867,6 +911,9 @@ .rounded-lg { border-radius: var(--radius-lg); } + .rounded-md { + border-radius: var(--radius-md); + } .rounded-xl { border-radius: var(--radius-xl); } @@ -1012,6 +1059,12 @@ .bg-green { background-color: var(--green); } + .bg-green\/10 { + background-color: var(--green); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--green) 10%, transparent); + } + } .bg-green\/20 { background-color: var(--green); @supports (color: color-mix(in lab, red, red)) { @@ -1030,6 +1083,18 @@ .bg-mauve { background-color: var(--mauve); } + .bg-overlay0\/10 { + background-color: var(--overlay0); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--overlay0) 10%, transparent); + } + } + .bg-overlay0\/20 { + background-color: var(--overlay0); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--overlay0) 20%, transparent); + } + } .bg-peach { background-color: var(--peach); } @@ -1039,6 +1104,12 @@ background-color: color-mix(in oklab, var(--peach) 5%, transparent); } } + .bg-peach\/10 { + background-color: var(--peach); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--peach) 10%, transparent); + } + } .bg-peach\/20 { background-color: var(--peach); @supports (color: color-mix(in lab, red, red)) { @@ -1060,12 +1131,24 @@ background-color: color-mix(in oklab, var(--red) 10%, transparent); } } + .bg-red\/15 { + background-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--red) 15%, transparent); + } + } .bg-red\/20 { background-color: var(--red); @supports (color: color-mix(in lab, red, red)) { background-color: color-mix(in oklab, var(--red) 20%, transparent); } } + .bg-red\/30 { + background-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--red) 30%, transparent); + } + } .bg-sapphire { background-color: var(--sapphire); } @@ -1123,6 +1206,9 @@ .p-8 { padding: calc(var(--spacing) * 8); } + .px-1 { + padding-inline: calc(var(--spacing) * 1); + } .px-1\.5 { padding-inline: calc(var(--spacing) * 1.5); } @@ -1156,6 +1242,9 @@ .py-2 { padding-block: calc(var(--spacing) * 2); } + .py-2\.5 { + padding-block: calc(var(--spacing) * 2.5); + } .py-3 { padding-block: calc(var(--spacing) * 3); } @@ -1189,6 +1278,9 @@ .pr-2 { padding-right: calc(var(--spacing) * 2); } + .pr-4 { + padding-right: calc(var(--spacing) * 4); + } .pr-10 { padding-right: calc(var(--spacing) * 10); } @@ -1204,6 +1296,9 @@ .pl-3 { padding-left: calc(var(--spacing) * 3); } + .pl-4 { + padding-left: calc(var(--spacing) * 4); + } .text-center { text-align: center; } @@ -1228,6 +1323,10 @@ font-size: var(--text-4xl); line-height: var(--tw-leading, var(--text-4xl--line-height)); } + .text-5xl { + font-size: var(--text-5xl); + line-height: var(--tw-leading, var(--text-5xl--line-height)); + } .text-9xl { font-size: var(--text-9xl); line-height: var(--tw-leading, var(--text-9xl--line-height)); @@ -1544,6 +1643,12 @@ transition-duration: var(--tw-duration, var(--default-transition-duration)); } } + .last\:border-b-0 { + &:last-child { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 0px; + } + } .hover\:-translate-y-0\.5 { &:hover { @media (hover: hover) { @@ -2026,6 +2131,11 @@ width: calc(var(--spacing) * 10); } } + .sm\:w-36 { + @media (width >= 40rem) { + width: calc(var(--spacing) * 36); + } + } .sm\:w-auto { @media (width >= 40rem) { width: auto; @@ -2098,6 +2208,16 @@ gap: calc(var(--spacing) * 2); } } + .sm\:gap-8 { + @media (width >= 40rem) { + gap: calc(var(--spacing) * 8); + } + } + .sm\:gap-10 { + @media (width >= 40rem) { + gap: calc(var(--spacing) * 10); + } + } .sm\:p-6 { @media (width >= 40rem) { padding: calc(var(--spacing) * 6); @@ -2128,12 +2248,30 @@ text-align: left; } } + .sm\:text-2xl { + @media (width >= 40rem) { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + } .sm\:text-4xl { @media (width >= 40rem) { font-size: var(--text-4xl); line-height: var(--tw-leading, var(--text-4xl--line-height)); } } + .sm\:text-6xl { + @media (width >= 40rem) { + font-size: var(--text-6xl); + line-height: var(--tw-leading, var(--text-6xl--line-height)); + } + } + .sm\:text-xl { + @media (width >= 40rem) { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + } .md\:col-span-2 { @media (width >= 48rem) { grid-column: span 2 / span 2; diff --git a/internal/handlers/fixture_detail.go b/internal/handlers/fixture_detail.go index e662696..c6398d9 100644 --- a/internal/handlers/fixture_detail.go +++ b/internal/handlers/fixture_detail.go @@ -47,6 +47,7 @@ func FixtureDetailPage( var rosters map[string][]*db.PlayerWithPlayStatus var nominatedFreeAgents []*db.FixtureFreeAgent var availableFreeAgents []*db.SeasonLeagueFreeAgent + var previewData *db.MatchPreviewData if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error @@ -94,6 +95,15 @@ func FixtureDetailPage( return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague") } } + + // Fetch match preview data for preview and analysis tabs + if activeTab == "preview" || activeTab == "analysis" { + previewData, err = db.ComputeMatchPreview(ctx, tx, fixture) + if err != nil { + return false, errors.Wrap(err, "db.ComputeMatchPreview") + } + } + return true, nil }); !ok { return @@ -102,6 +112,7 @@ func FixtureDetailPage( renderSafely(seasonsview.FixtureDetailPage( fixture, currentSchedule, history, canSchedule, userTeamID, result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents, + previewData, ), s, r, w) }) } diff --git a/internal/view/seasonsview/fixture_detail.templ b/internal/view/seasonsview/fixture_detail.templ index 26e8b77..4918db7 100644 --- a/internal/view/seasonsview/fixture_detail.templ +++ b/internal/view/seasonsview/fixture_detail.templ @@ -20,6 +20,7 @@ templ FixtureDetailPage( activeTab string, nominatedFreeAgents []*db.FixtureFreeAgent, availableFreeAgents []*db.SeasonLeagueFreeAgent, + previewData *db.MatchPreviewData, ) { {{ permCache := contexts.Permissions(ctx) @@ -33,6 +34,14 @@ templ FixtureDetailPage( if isFinalized && activeTab == "schedule" { activeTab = "overview" } + // Redirect preview โ†’ analysis once finalized + if isFinalized && activeTab == "preview" { + activeTab = "analysis" + } + // Redirect analysis โ†’ preview if not finalized + if !isFinalized && activeTab == "analysis" { + activeTab = "preview" + } }} @baseview.Layout(fmt.Sprintf("%s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
    @@ -71,19 +80,26 @@ templ FixtureDetailPage(
    - - if !isFinalized { -
    if activeTab == "overview" { @fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents) + } else if activeTab == "preview" && previewData != nil { + @fixtureMatchPreviewTab(fixture, rosters, previewData) + } else if activeTab == "analysis" && result != nil && result.Finalized { + @fixtureMatchAnalysisTab(fixture, result, rosters, previewData) } else if activeTab == "schedule" { @fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID) } diff --git a/internal/view/seasonsview/fixture_match_analysis.templ b/internal/view/seasonsview/fixture_match_analysis.templ new file mode 100644 index 0000000..854256c --- /dev/null +++ b/internal/view/seasonsview/fixture_match_analysis.templ @@ -0,0 +1,611 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" +import "sort" +import "strings" + +// teamAggStats holds aggregated stats for a single team in a fixture. +type teamAggStats struct { + Goals int + Assists int + PrimaryAssists int + SecondaryAssists int + Saves int + Shots int + Blocks int + Passes int + Turnovers int + Takeaways int + FaceoffsWon int + FaceoffsLost int + PostHits int + PossessionSec int + PlayersUsed int +} + +func aggregateTeamStats(players []*db.PlayerWithPlayStatus) *teamAggStats { + agg := &teamAggStats{} + for _, p := range players { + if !p.Played || p.Stats == nil { + continue + } + agg.PlayersUsed++ + if p.Stats.Goals != nil { + agg.Goals += *p.Stats.Goals + } + if p.Stats.Assists != nil { + agg.Assists += *p.Stats.Assists + } + if p.Stats.PrimaryAssists != nil { + agg.PrimaryAssists += *p.Stats.PrimaryAssists + } + if p.Stats.SecondaryAssists != nil { + agg.SecondaryAssists += *p.Stats.SecondaryAssists + } + if p.Stats.Saves != nil { + agg.Saves += *p.Stats.Saves + } + if p.Stats.Shots != nil { + agg.Shots += *p.Stats.Shots + } + if p.Stats.Blocks != nil { + agg.Blocks += *p.Stats.Blocks + } + if p.Stats.Passes != nil { + agg.Passes += *p.Stats.Passes + } + if p.Stats.Turnovers != nil { + agg.Turnovers += *p.Stats.Turnovers + } + if p.Stats.Takeaways != nil { + agg.Takeaways += *p.Stats.Takeaways + } + if p.Stats.FaceoffsWon != nil { + agg.FaceoffsWon += *p.Stats.FaceoffsWon + } + if p.Stats.FaceoffsLost != nil { + agg.FaceoffsLost += *p.Stats.FaceoffsLost + } + if p.Stats.PostHits != nil { + agg.PostHits += *p.Stats.PostHits + } + if p.Stats.PossessionTimeSec != nil { + agg.PossessionSec += *p.Stats.PossessionTimeSec + } + } + return agg +} + +func formatPossession(seconds int) string { + m := seconds / 60 + s := seconds % 60 + return fmt.Sprintf("%d:%02d", m, s) +} + +func faceoffPct(won, lost int) string { + total := won + lost + if total == 0 { + return "0%" + } + pct := float64(won) / float64(total) * 100 + return fmt.Sprintf("%.0f%%", pct) +} + +// fixtureMatchAnalysisTab renders the full Match Analysis tab for completed fixtures. +// Shows score, team stats comparison, match details, and top performers. +templ fixtureMatchAnalysisTab( + fixture *db.Fixture, + result *db.FixtureResult, + rosters map[string][]*db.PlayerWithPlayStatus, + preview *db.MatchPreviewData, +) { +
    + + @analysisScoreHeader(fixture, result) + + + @analysisTeamStatsComparison(fixture, rosters) + + + @analysisTopPerformers(fixture, rosters) + + + if preview != nil { + @analysisStandingsContext(fixture, preview) + } +
    +} + +// analysisScoreHeader renders the final score in a prominent broadcast-style display. +templ analysisScoreHeader(fixture *db.Fixture, result *db.FixtureResult) { + {{ + isOT := strings.EqualFold(result.EndReason, "Overtime") + homeWon := result.Winner == "home" + awayWon := result.Winner == "away" + isForfeit := result.IsForfeit + }} +
    +
    +

    Final Score

    +
    +
    + if isForfeit { + @analysisForfeitDisplay(fixture, result) + } else { +
    + +
    + if fixture.HomeTeam.Color != "" { +
    + } +

    + @links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League) +

    + + { fmt.Sprint(result.HomeScore) } + + if homeWon { + Winner + } +
    + +
    + โ€“ + if isOT { + OT + } +
    + +
    + if fixture.AwayTeam.Color != "" { +
    + } +

    + @links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League) +

    + + { fmt.Sprint(result.AwayScore) } + + if awayWon { + Winner + } +
    +
    + } +
    +
    +} + +// analysisForfeitDisplay renders a forfeit result in the analysis header. +templ analysisForfeitDisplay(fixture *db.Fixture, result *db.FixtureResult) { + {{ + isMutualForfeit := result.ForfeitType != nil && *result.ForfeitType == "mutual" + isOutrightForfeit := result.ForfeitType != nil && *result.ForfeitType == "outright" + forfeitTeamName := "" + winnerTeamName := "" + if isOutrightForfeit && result.ForfeitTeam != nil { + if *result.ForfeitTeam == "home" { + forfeitTeamName = fixture.HomeTeam.Name + winnerTeamName = fixture.AwayTeam.Name + } else { + forfeitTeamName = fixture.AwayTeam.Name + winnerTeamName = fixture.HomeTeam.Name + } + } + }} +
    + if isMutualForfeit { + MUTUAL FORFEIT +

    Both teams receive an overtime loss

    + } else if isOutrightForfeit { + FORFEIT +

    + { forfeitTeamName } forfeited โ€” { winnerTeamName } wins +

    + } + if result.ForfeitReason != nil && *result.ForfeitReason != "" { +
    +

    Reason

    +

    { *result.ForfeitReason }

    +
    + } +
    +} + +// analysisTeamStatsComparison renders aggregated team stats in the broadcast comparison layout. +templ analysisTeamStatsComparison(fixture *db.Fixture, rosters map[string][]*db.PlayerWithPlayStatus) { + {{ + homeAgg := aggregateTeamStats(rosters["home"]) + awayAgg := aggregateTeamStats(rosters["away"]) + }} +
    +
    +

    Team Statistics

    +
    +
    + +
    +
    +
    + if fixture.HomeTeam.Color != "" { + + } + { fixture.HomeTeam.ShortName } +
    +
    +
    +
    +
    + { fixture.AwayTeam.ShortName } + if fixture.AwayTeam.Color != "" { + + } +
    +
    +
    + +
    + @previewStatRow( + fmt.Sprint(homeAgg.Goals), + "Goals", + fmt.Sprint(awayAgg.Goals), + homeAgg.Goals > awayAgg.Goals, + awayAgg.Goals > homeAgg.Goals, + ) + @previewStatRow( + fmt.Sprint(homeAgg.Assists), + "Assists", + fmt.Sprint(awayAgg.Assists), + homeAgg.Assists > awayAgg.Assists, + awayAgg.Assists > homeAgg.Assists, + ) + @previewStatRow( + fmt.Sprint(homeAgg.Shots), + "Shots", + fmt.Sprint(awayAgg.Shots), + homeAgg.Shots > awayAgg.Shots, + awayAgg.Shots > homeAgg.Shots, + ) + @previewStatRow( + fmt.Sprint(homeAgg.Saves), + "Saves", + fmt.Sprint(awayAgg.Saves), + homeAgg.Saves > awayAgg.Saves, + awayAgg.Saves > homeAgg.Saves, + ) + @previewStatRow( + fmt.Sprint(homeAgg.Blocks), + "Blocks", + fmt.Sprint(awayAgg.Blocks), + homeAgg.Blocks > awayAgg.Blocks, + awayAgg.Blocks > homeAgg.Blocks, + ) + @previewStatRow( + fmt.Sprint(homeAgg.Passes), + "Passes", + fmt.Sprint(awayAgg.Passes), + homeAgg.Passes > awayAgg.Passes, + awayAgg.Passes > homeAgg.Passes, + ) + @previewStatRow( + fmt.Sprint(homeAgg.Takeaways), + "Takeaways", + fmt.Sprint(awayAgg.Takeaways), + homeAgg.Takeaways > awayAgg.Takeaways, + awayAgg.Takeaways > homeAgg.Takeaways, + ) + @previewStatRow( + fmt.Sprint(homeAgg.Turnovers), + "Turnovers", + fmt.Sprint(awayAgg.Turnovers), + homeAgg.Turnovers < awayAgg.Turnovers, + awayAgg.Turnovers < homeAgg.Turnovers, + ) + + {{ + homeFO := homeAgg.FaceoffsWon + homeAgg.FaceoffsLost + awayFO := awayAgg.FaceoffsWon + awayAgg.FaceoffsLost + homeFOStr := fmt.Sprintf("%d/%d", homeAgg.FaceoffsWon, homeFO) + awayFOStr := fmt.Sprintf("%d/%d", awayAgg.FaceoffsWon, awayFO) + }} + @previewStatRow( + homeFOStr, + "Faceoffs Won", + awayFOStr, + homeAgg.FaceoffsWon > awayAgg.FaceoffsWon, + awayAgg.FaceoffsWon > homeAgg.FaceoffsWon, + ) + @previewStatRow( + faceoffPct(homeAgg.FaceoffsWon, homeAgg.FaceoffsLost), + "Faceoff %", + faceoffPct(awayAgg.FaceoffsWon, awayAgg.FaceoffsLost), + homeAgg.FaceoffsWon * (awayAgg.FaceoffsWon + awayAgg.FaceoffsLost) > awayAgg.FaceoffsWon * (homeAgg.FaceoffsWon + homeAgg.FaceoffsLost), + awayAgg.FaceoffsWon * (homeAgg.FaceoffsWon + homeAgg.FaceoffsLost) > homeAgg.FaceoffsWon * (awayAgg.FaceoffsWon + awayAgg.FaceoffsLost), + ) + @previewStatRow( + fmt.Sprint(homeAgg.PostHits), + "Post Hits", + fmt.Sprint(awayAgg.PostHits), + homeAgg.PostHits > awayAgg.PostHits, + awayAgg.PostHits > homeAgg.PostHits, + ) + @previewStatRow( + formatPossession(homeAgg.PossessionSec), + "Possession", + formatPossession(awayAgg.PossessionSec), + homeAgg.PossessionSec > awayAgg.PossessionSec, + awayAgg.PossessionSec > homeAgg.PossessionSec, + ) + @previewStatRow( + fmt.Sprint(homeAgg.PlayersUsed), + "Players Used", + fmt.Sprint(awayAgg.PlayersUsed), + false, + false, + ) +
    +
    +
    +} + +// analysisTopPerformers shows the top players from each team based on score. +templ analysisTopPerformers(fixture *db.Fixture, rosters map[string][]*db.PlayerWithPlayStatus) { + {{ + // Collect players who played and have stats, sorted by score descending + type scoredPlayer struct { + Player *db.Player + Stats *db.FixtureResultPlayerStats + IsManager bool + IsFreeAgent bool + } + + collectTop := func(players []*db.PlayerWithPlayStatus, limit int) []*scoredPlayer { + var scored []*scoredPlayer + for _, p := range players { + if !p.Played || p.Stats == nil || p.Player == nil { + continue + } + scored = append(scored, &scoredPlayer{ + Player: p.Player, + Stats: p.Stats, + IsManager: p.IsManager, + IsFreeAgent: p.IsFreeAgent, + }) + } + sort.Slice(scored, func(i, j int) bool { + si, sj := 0, 0 + if scored[i].Stats.Score != nil { + si = *scored[i].Stats.Score + } + if scored[j].Stats.Score != nil { + sj = *scored[j].Stats.Score + } + return si > sj + }) + if len(scored) > limit { + scored = scored[:limit] + } + return scored + } + + homeTop := collectTop(rosters["home"], 3) + awayTop := collectTop(rosters["away"], 3) + }} + if len(homeTop) > 0 || len(awayTop) > 0 { +
    +
    +

    Top Performers

    +
    +
    +
    + +
    +
    + if fixture.HomeTeam.Color != "" { + + } +

    { fixture.HomeTeam.Name }

    +
    +
    + for i, p := range homeTop { + @topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1) + } +
    +
    + +
    +
    + if fixture.AwayTeam.Color != "" { + + } +

    { fixture.AwayTeam.Name }

    +
    +
    + for i, p := range awayTop { + @topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1) + } +
    +
    +
    +
    +
    + } +} + +// topPerformerCard renders a single top performer card with key stats. +templ topPerformerCard(player *db.Player, stats *db.FixtureResultPlayerStats, isManager bool, isFreeAgent bool, rank int) { + {{ + rankLabels := map[int]string{1: "๐Ÿฅ‡", 2: "๐Ÿฅˆ", 3: "๐Ÿฅ‰"} + rankLabel := rankLabels[rank] + }} +
    + { rankLabel } +
    +
    + + @links.PlayerLink(player) + + if isManager { + + ★ + + } + if isFreeAgent { + + FA + + } +
    +
    + if stats.Score != nil { + { fmt.Sprint(*stats.Score) } SC + } + if stats.Goals != nil { + { fmt.Sprint(*stats.Goals) } G + } + if stats.Assists != nil { + { fmt.Sprint(*stats.Assists) } A + } + if stats.Saves != nil { + { fmt.Sprint(*stats.Saves) } SV + } + if stats.Shots != nil { + { fmt.Sprint(*stats.Shots) } SH + } +
    +
    +
    +} + +// analysisStandingsContext shows how this result fits into the league standings. +templ analysisStandingsContext(fixture *db.Fixture, preview *db.MatchPreviewData) { +
    +
    +

    League Context

    +
    +
    + +
    +
    +
    + if fixture.HomeTeam.Color != "" { + + } + { fixture.HomeTeam.ShortName } +
    +
    +
    +
    +
    + { fixture.AwayTeam.ShortName } + if fixture.AwayTeam.Color != "" { + + } +
    +
    +
    +
    + {{ + homePos := ordinal(preview.HomePosition) + awayPos := ordinal(preview.AwayPosition) + if preview.HomePosition == 0 { + homePos = "N/A" + } + if preview.AwayPosition == 0 { + awayPos = "N/A" + } + }} + @previewStatRow( + homePos, + "Position", + awayPos, + preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition, + preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Points), + "Points", + fmt.Sprint(preview.AwayRecord.Points), + preview.HomeRecord.Points > preview.AwayRecord.Points, + preview.AwayRecord.Points > preview.HomeRecord.Points, + ) + @previewStatRow( + fmt.Sprintf("%d-%d-%d-%d", + preview.HomeRecord.Wins, + preview.HomeRecord.OvertimeWins, + preview.HomeRecord.OvertimeLosses, + preview.HomeRecord.Losses, + ), + "Record", + fmt.Sprintf("%d-%d-%d-%d", + preview.AwayRecord.Wins, + preview.AwayRecord.OvertimeWins, + preview.AwayRecord.OvertimeLosses, + preview.AwayRecord.Losses, + ), + false, + false, + ) + {{ + homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst + awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst + }} + @previewStatRow( + fmt.Sprintf("%+d", homeDiff), + "Goal Diff", + fmt.Sprintf("%+d", awayDiff), + homeDiff > awayDiff, + awayDiff > homeDiff, + ) + + if len(preview.HomeRecentGames) > 0 || len(preview.AwayRecentGames) > 0 { +
    +
    +
    + for _, g := range preview.HomeRecentGames { + @gameOutcomeIcon(g) + } +
    +
    +
    + Form +
    +
    +
    + for _, g := range preview.AwayRecentGames { + @gameOutcomeIcon(g) + } +
    +
    +
    + } +
    +
    +
    +} diff --git a/internal/view/seasonsview/fixture_match_preview.templ b/internal/view/seasonsview/fixture_match_preview.templ new file mode 100644 index 0000000..7a7e91d --- /dev/null +++ b/internal/view/seasonsview/fixture_match_preview.templ @@ -0,0 +1,435 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" +import "sort" + +// fixtureMatchPreviewTab renders the full Match Preview tab content. +// Shows team standings comparison, recent form, and full rosters side-by-side. +templ fixtureMatchPreviewTab( + fixture *db.Fixture, + rosters map[string][]*db.PlayerWithPlayStatus, + preview *db.MatchPreviewData, +) { +
    + + @matchPreviewHeader(fixture, preview) + + + @matchPreviewFormGuide(fixture, preview) + + + @matchPreviewRosters(fixture, rosters) +
    +} + +// matchPreviewHeader renders the broadcast-style team comparison with standings. +templ matchPreviewHeader(fixture *db.Fixture, preview *db.MatchPreviewData) { +
    +
    +

    Team Comparison

    +
    +
    + +
    + +
    + if fixture.HomeTeam.Color != "" { +
    + } +

    + @links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League) +

    + { fixture.HomeTeam.ShortName } +
    + +
    + VS +
    + +
    + if fixture.AwayTeam.Color != "" { +
    + } +

    + @links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League) +

    + { fixture.AwayTeam.ShortName } +
    +
    + + {{ + homePos := ordinal(preview.HomePosition) + awayPos := ordinal(preview.AwayPosition) + if preview.HomePosition == 0 { + homePos = "N/A" + } + if preview.AwayPosition == 0 { + awayPos = "N/A" + } + }} +
    + + @previewStatRow( + homePos, + "Position", + awayPos, + preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition, + preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition, + ) + + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Points), + "Points", + fmt.Sprint(preview.AwayRecord.Points), + preview.HomeRecord.Points > preview.AwayRecord.Points, + preview.AwayRecord.Points > preview.HomeRecord.Points, + ) + + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Played), + "Played", + fmt.Sprint(preview.AwayRecord.Played), + false, + false, + ) + + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Wins), + "Wins", + fmt.Sprint(preview.AwayRecord.Wins), + preview.HomeRecord.Wins > preview.AwayRecord.Wins, + preview.AwayRecord.Wins > preview.HomeRecord.Wins, + ) + + @previewStatRow( + fmt.Sprint(preview.HomeRecord.OvertimeWins), + "OT Wins", + fmt.Sprint(preview.AwayRecord.OvertimeWins), + preview.HomeRecord.OvertimeWins > preview.AwayRecord.OvertimeWins, + preview.AwayRecord.OvertimeWins > preview.HomeRecord.OvertimeWins, + ) + + @previewStatRow( + fmt.Sprint(preview.HomeRecord.OvertimeLosses), + "OT Losses", + fmt.Sprint(preview.AwayRecord.OvertimeLosses), + preview.HomeRecord.OvertimeLosses < preview.AwayRecord.OvertimeLosses, + preview.AwayRecord.OvertimeLosses < preview.HomeRecord.OvertimeLosses, + ) + + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Losses), + "Losses", + fmt.Sprint(preview.AwayRecord.Losses), + preview.HomeRecord.Losses < preview.AwayRecord.Losses, + preview.AwayRecord.Losses < preview.HomeRecord.Losses, + ) + + @previewStatRow( + fmt.Sprint(preview.HomeRecord.GoalsFor), + "Goals For", + fmt.Sprint(preview.AwayRecord.GoalsFor), + preview.HomeRecord.GoalsFor > preview.AwayRecord.GoalsFor, + preview.AwayRecord.GoalsFor > preview.HomeRecord.GoalsFor, + ) + + @previewStatRow( + fmt.Sprint(preview.HomeRecord.GoalsAgainst), + "Goals Against", + fmt.Sprint(preview.AwayRecord.GoalsAgainst), + preview.HomeRecord.GoalsAgainst < preview.AwayRecord.GoalsAgainst, + preview.AwayRecord.GoalsAgainst < preview.HomeRecord.GoalsAgainst, + ) + + {{ + homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst + awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst + homeDiffStr := fmt.Sprintf("%+d", homeDiff) + awayDiffStr := fmt.Sprintf("%+d", awayDiff) + }} + @previewStatRow( + homeDiffStr, + "Goal Diff", + awayDiffStr, + homeDiff > awayDiff, + awayDiff > homeDiff, + ) +
    +
    +
    +} + +// previewStatRow renders a single comparison stat row in the broadcast-style layout. +// The stat label is centered, with home value on the left and away value on the right. +// homeHighlight/awayHighlight indicate which side has the better value. +templ previewStatRow(homeValue, label, awayValue string, homeHighlight, awayHighlight bool) { +
    + +
    + + { homeValue } + +
    + +
    + { label } +
    + +
    + + { awayValue } + +
    +
    +} + +// matchPreviewFormGuide renders the recent form section with last 5 game outcome icons. +templ matchPreviewFormGuide(fixture *db.Fixture, preview *db.MatchPreviewData) { +
    +
    +

    Recent Form

    +
    +
    +
    + +
    +
    + if fixture.HomeTeam.Color != "" { + + } +

    { fixture.HomeTeam.Name }

    +
    + if len(preview.HomeRecentGames) == 0 { +

    No recent matches played

    + } else { + +
    + for _, g := range preview.HomeRecentGames { + @gameOutcomeIcon(g) + } +
    + +
    + for i := len(preview.HomeRecentGames) - 1; i >= 0; i-- { + @recentGameRow(preview.HomeRecentGames[i]) + } +
    + } +
    + +
    +
    + if fixture.AwayTeam.Color != "" { + + } +

    { fixture.AwayTeam.Name }

    +
    + if len(preview.AwayRecentGames) == 0 { +

    No recent matches played

    + } else { + +
    + for _, g := range preview.AwayRecentGames { + @gameOutcomeIcon(g) + } +
    + +
    + for i := len(preview.AwayRecentGames) - 1; i >= 0; i-- { + @recentGameRow(preview.AwayRecentGames[i]) + } +
    + } +
    +
    +
    +
    +} + +// outcomeStyle holds the styling info for a game outcome type. +type outcomeStyle struct { + iconBg string // Background class for the icon badge + rowBg string // Background class for the row + text string // Text color class + label string // Short label (W, L, OW, OL, D, F) + fullLabel string // Full label for row display (W, OTW, OTL, L, D, FF) + desc string // Human-readable description (Win, Loss, etc.) +} + +func getOutcomeStyle(outcomeType string) outcomeStyle { + switch outcomeType { + case "W": + return outcomeStyle{"bg-green/20", "bg-green/10", "text-green", "W", "W", "Win"} + case "OTW": + return outcomeStyle{"bg-yellow/20", "bg-yellow/10", "text-yellow", "OW", "OTW", "OT Win"} + case "OTL": + return outcomeStyle{"bg-peach/20", "bg-peach/10", "text-peach", "OL", "OTL", "OT Loss"} + case "L": + return outcomeStyle{"bg-red/20", "bg-red/10", "text-red", "L", "L", "Loss"} + case "D": + return outcomeStyle{"bg-overlay0/20", "bg-overlay0/10", "text-overlay0", "D", "D", "Draw"} + case "F": + return outcomeStyle{"bg-red/30", "bg-red/15", "text-red", "F", "FF", "Forfeit"} + default: + return outcomeStyle{"bg-surface1", "bg-surface0", "text-subtext0", "?", "?", "Unknown"} + } +} + +// gameOutcomeIcon renders a single game outcome as a colored badge. +templ gameOutcomeIcon(outcome *db.GameOutcome) { + {{ + style := getOutcomeStyle(outcome.Type) + tooltip := "" + if outcome.Opponent != nil { + tooltip = fmt.Sprintf("%s vs %s", style.desc, outcome.Opponent.Name) + if outcome.IsForfeit { + tooltip += " (Forfeit)" + } else if outcome.Score != "" { + tooltip += fmt.Sprintf(" (%s)", outcome.Score) + } + } + }} + + { style.label } + +} + +// recentGameRow renders a single recent game result as a compact row. +templ recentGameRow(outcome *db.GameOutcome) { + {{ + style := getOutcomeStyle(outcome.Type) + opponentName := "Unknown" + if outcome.Opponent != nil { + opponentName = outcome.Opponent.Name + } + }} +
    + { style.fullLabel } + vs { opponentName } + if outcome.IsForfeit { + Forfeit + } else if outcome.Score != "" { + { outcome.Score } + } +
    +} + +// matchPreviewRosters renders team rosters side-by-side for the match preview. +templ matchPreviewRosters( + fixture *db.Fixture, + rosters map[string][]*db.PlayerWithPlayStatus, +) { + {{ + homePlayers := rosters["home"] + awayPlayers := rosters["away"] + }} +
    +
    +

    Team Rosters

    +
    +
    +
    + + @previewRosterColumn(fixture.HomeTeam, homePlayers, fixture.Season, fixture.League) + + @previewRosterColumn(fixture.AwayTeam, awayPlayers, fixture.Season, fixture.League) +
    +
    +
    +} + +// previewRosterColumn renders a single team's roster for the match preview. +templ previewRosterColumn( + team *db.Team, + players []*db.PlayerWithPlayStatus, + season *db.Season, + league *db.League, +) { + {{ + // Separate managers and regular players + var managers []*db.PlayerWithPlayStatus + var roster []*db.PlayerWithPlayStatus + for _, p := range players { + if p.IsManager { + managers = append(managers, p) + } else { + roster = append(roster, p) + } + } + // Sort roster alphabetically by display name + sort.Slice(roster, func(i, j int) bool { + return roster[i].Player.DisplayName() < roster[j].Player.DisplayName() + }) + }} +
    + +
    +
    + if team.Color != "" { + + } +

    + @links.TeamNameLinkInSeason(team, season, league) +

    +
    + + { fmt.Sprint(len(players)) } players + +
    + if len(players) == 0 { +

    No players on roster.

    + } else { +
    + + for _, p := range managers { +
    + + ★ + + + @links.PlayerLink(p.Player) + + if p.IsFreeAgent { + + FA + + } +
    + } + + for _, p := range roster { +
    + + @links.PlayerLink(p.Player) + + if p.IsFreeAgent { + + FA + + } +
    + } +
    + } +
    +} From 7b934295c6d69bee5e6effe6fea3d0b2b7a3d39c Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sat, 7 Mar 2026 11:53:49 +1100 Subject: [PATCH 15/33] removed theme switcher, always uses dark theme --- internal/embedfs/web/css/output.css | 11 --------- internal/view/baseview/footer.templ | 38 +---------------------------- internal/view/baseview/layout.templ | 7 +----- 3 files changed, 2 insertions(+), 54 deletions(-) diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index b9f0c88..555f0fd 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -341,9 +341,6 @@ .mt-1 { margin-top: calc(var(--spacing) * 1); } - .mt-1\.5 { - margin-top: calc(var(--spacing) * 1.5); - } .mt-2 { margin-top: calc(var(--spacing) * 2); } @@ -570,9 +567,6 @@ .w-80 { width: calc(var(--spacing) * 80); } - .w-fit { - width: fit-content; - } .w-full { width: 100%; } @@ -2322,11 +2316,6 @@ display: flex; } } - .lg\:inline { - @media (width >= 64rem) { - display: inline; - } - } .lg\:grid-cols-2 { @media (width >= 64rem) { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/internal/view/baseview/footer.templ b/internal/view/baseview/footer.templ index 29b2503..3347c50 100644 --- a/internal/view/baseview/footer.templ +++ b/internal/view/baseview/footer.templ @@ -15,7 +15,7 @@ func getFooterItems() []FooterItem { // Returns the template fragment for the Footer templ Footer() {
    -
    +
    @backToTopButton()
    @footerBranding() @@ -23,7 +23,6 @@ templ Footer() {
    @footerCopyright() - @themeSelector()
    @@ -91,38 +90,3 @@ templ footerCopyright() {

    } - -templ themeSelector() { -
    -
    - - - -
    -
    -} diff --git a/internal/view/baseview/layout.templ b/internal/view/baseview/layout.templ index 4e36144..96a0c3c 100644 --- a/internal/view/baseview/layout.templ +++ b/internal/view/baseview/layout.templ @@ -11,13 +11,8 @@ templ Layout(title string) { - { title } @@ -34,7 +29,7 @@ templ Layout(title string) { } From 8b414ff7f071ea88c18390ee217e565b175685c6 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sat, 7 Mar 2026 12:22:11 +1100 Subject: [PATCH 16/33] changed registration to have blank username on first load --- internal/handlers/register.go | 5 ++-- internal/view/authview/register_form.templ | 33 +++++++++++----------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/internal/handlers/register.go b/internal/handlers/register.go index 61bbb24..99ba067 100644 --- a/internal/handlers/register.go +++ b/internal/handlers/register.go @@ -54,14 +54,14 @@ func Register( store.ClearRedirectTrack(r, "/register") if r.Method == "GET" { - renderSafely(authview.RegisterPage(details.DiscordUser.Username), s, r, w) + renderSafely(authview.RegisterPage(""), s, r, w) return } username := r.FormValue("username") unique := false var user *db.User audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user) - if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + if ok := conn.WithWriteTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { unique, err = db.IsUnique(ctx, tx, (*db.User)(nil), "username", username) if err != nil { return false, errors.Wrap(err, "db.IsUsernameUnique") @@ -79,6 +79,7 @@ func Register( } return true, nil }); !ok { + throw.InternalServiceError(s, w, r, "Registration failed", err) return } if !unique { diff --git a/internal/view/authview/register_form.templ b/internal/view/authview/register_form.templ index b8aa722..1816ce0 100644 --- a/internal/view/authview/register_form.templ +++ b/internal/view/authview/register_form.templ @@ -24,19 +24,19 @@ templ RegisterFormForm(username string) { this.isChecking = false; this.isUnique = false; }, - enableSubmit() { - this.canSubmit = true; - }, + enableSubmit() { + this.canSubmit = true; + }, handleSubmit() { this.isSubmitting = true; - this.buttontext = 'Loading...'; + this.buttontext = "Loading..."; // Set timeout for 10 seconds this.submitTimeout = setTimeout(() => { this.isSubmitting = false; - this.buttontext = 'Register'; - this.errorMessage = 'Request timed out. Please try again.'; + this.buttontext = "Register"; + this.errorMessage = "Request timed out. Please try again."; }, 10000); - } + }, }; } @@ -49,7 +49,7 @@ templ RegisterFormForm(username string) { type="text" id="username" name="username" - x-bind:class="{ + x-bind:class="{ 'py-3 px-4 block w-full rounded-lg text-sm bg-base disabled:opacity-50 disabled:pointer-events-none border-2 outline-none': true, 'border-overlay0 focus:border-blue': !isUnique && !errorMessage, 'border-green focus:border-green': isUnique && !isChecking && !errorMessage, @@ -60,19 +60,18 @@ templ RegisterFormForm(username string) { value={ username } @input="resetErr(); isEmpty = $el.value.trim() === ''; if(isEmpty) { errorMessage='Username is required'; isUnique=false; }" hx-post="/htmx/isusernameunique" - hx-trigger="load delay:100ms, input changed delay:500ms" + hx-trigger="input changed delay:500ms" hx-swap="none" @htmx:before-request="if($el.value.trim() === '') { isEmpty=true; return; } isEmpty=false; isChecking=true; isUnique=false; errorMessage=''" @htmx:after-request="isChecking=false; if($event.detail.successful) { isUnique=true; canSubmit=true; } else if($event.detail.xhr.status === 409) { errorMessage='Username is already taken'; isUnique=false; canSubmit=false; }" /> - -

    +

    -
    - - if activeTab == "overview" { - @fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents) - } else if activeTab == "preview" && previewData != nil { - @fixtureMatchPreviewTab(fixture, rosters, previewData) - } else if activeTab == "analysis" && result != nil && result.Finalized { - @fixtureMatchAnalysisTab(fixture, result, rosters, previewData) - } else if activeTab == "schedule" { - @fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID) - } + +
    + { children... } +
    + } } @@ -113,14 +81,15 @@ templ fixtureTabItem(section string, label string, activeTab string, fixture *db baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2" activeClasses := "border-blue text-blue font-semibold" inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2" - url := fmt.Sprintf("/fixtures/%d", fixture.ID) - if section != "overview" { - url = fmt.Sprintf("/fixtures/%d?tab=%s", fixture.ID, section) - } + url := fmt.Sprintf("/fixtures/%d/%s", fixture.ID, section) }}
  • { label } @@ -128,6 +97,107 @@ templ fixtureTabItem(section string, label string, activeTab string, fixture *db
  • } +// ==================== Full page wrappers (for GET requests / direct navigation) ==================== + +templ FixtureDetailOverviewPage( + fixture *db.Fixture, + currentSchedule *db.FixtureSchedule, + canSchedule bool, + userTeamID int, + result *db.FixtureResult, + rosters map[string][]*db.PlayerWithPlayStatus, + nominatedFreeAgents []*db.FixtureFreeAgent, + availableFreeAgents []*db.SeasonLeagueFreeAgent, +) { + @FixtureDetailLayout("overview", fixture, result) { + @FixtureDetailOverviewContent(fixture, currentSchedule, canSchedule, userTeamID, result, rosters, nominatedFreeAgents, availableFreeAgents) + } +} + +templ FixtureDetailPreviewPage( + fixture *db.Fixture, + result *db.FixtureResult, + rosters map[string][]*db.PlayerWithPlayStatus, + previewData *db.MatchPreviewData, +) { + @FixtureDetailLayout("preview", fixture, result) { + @FixtureDetailPreviewContent(fixture, rosters, previewData) + } +} + +templ FixtureDetailAnalysisPage( + fixture *db.Fixture, + result *db.FixtureResult, + rosters map[string][]*db.PlayerWithPlayStatus, + previewData *db.MatchPreviewData, +) { + @FixtureDetailLayout("analysis", fixture, result) { + @FixtureDetailAnalysisContent(fixture, result, rosters, previewData) + } +} + +templ FixtureDetailSchedulePage( + fixture *db.Fixture, + currentSchedule *db.FixtureSchedule, + history []*db.FixtureSchedule, + canSchedule bool, + userTeamID int, +) { + @FixtureDetailLayout("scheduling", fixture, nil) { + @FixtureDetailScheduleContent(fixture, currentSchedule, history, canSchedule, userTeamID) + } +} + +// ==================== Tab content components (for POST requests / HTMX swaps) ==================== + +templ FixtureDetailOverviewContent( + fixture *db.Fixture, + currentSchedule *db.FixtureSchedule, + canSchedule bool, + userTeamID int, + result *db.FixtureResult, + rosters map[string][]*db.PlayerWithPlayStatus, + nominatedFreeAgents []*db.FixtureFreeAgent, + availableFreeAgents []*db.SeasonLeagueFreeAgent, +) { + {{ + permCache := contexts.Permissions(ctx) + canManage := permCache.HasPermission(permissions.FixturesManage) + }} + @fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents) +} + +templ FixtureDetailPreviewContent( + fixture *db.Fixture, + rosters map[string][]*db.PlayerWithPlayStatus, + previewData *db.MatchPreviewData, +) { + @fixtureMatchPreviewTab(fixture, rosters, previewData) +} + +templ FixtureDetailAnalysisContent( + fixture *db.Fixture, + result *db.FixtureResult, + rosters map[string][]*db.PlayerWithPlayStatus, + previewData *db.MatchPreviewData, +) { + @fixtureMatchAnalysisTab(fixture, result, rosters, previewData) +} + +templ FixtureDetailScheduleContent( + fixture *db.Fixture, + currentSchedule *db.FixtureSchedule, + history []*db.FixtureSchedule, + canSchedule bool, + userTeamID int, +) { + {{ + permCache := contexts.Permissions(ctx) + canManage := permCache.HasPermission(permissions.FixturesManage) + }} + @fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID) +} + // ==================== Overview Tab ==================== templ fixtureOverviewTab( fixture *db.Fixture, From 4064c9c557a35b3615ec65e167cf3505911647d4 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sat, 7 Mar 2026 12:36:15 +1100 Subject: [PATCH 18/33] fixed build recipe not generating templ files --- justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/justfile b/justfile index b2b5952..a6d9943 100644 --- a/justfile +++ b/justfile @@ -14,7 +14,7 @@ default: # Build the target binary [group('build')] -build target=entrypoint: tailwind (_build target) +build target=entrypoint: templ tailwind (_build target) _build target=entrypoint: tidy (generate target) go build -ldflags="-w -s" -o {{bin}}/{{target}} {{cmd}}/{{target}} From 3b1eeaf12deed55197d1508ac3143b975ead6a80 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sat, 7 Mar 2026 13:01:08 +1100 Subject: [PATCH 19/33] scroll fixes --- internal/embedfs/web/css/input.css | 85 +++++++---------------------- internal/embedfs/web/css/output.css | 63 +++++---------------- internal/view/baseview/footer.templ | 8 +-- internal/view/baseview/layout.templ | 14 +++-- 4 files changed, 49 insertions(+), 121 deletions(-) diff --git a/internal/embedfs/web/css/input.css b/internal/embedfs/web/css/input.css index 2686382..c36d633 100644 --- a/internal/embedfs/web/css/input.css +++ b/internal/embedfs/web/css/input.css @@ -129,72 +129,29 @@ } /* Custom Scrollbar Styles - Catppuccin Theme */ +/* Only applied to specific elements (viewport, dropdowns, modals) to avoid + overriding the browser default scrollbar on the html/body level */ -/* Firefox */ -* { +/* Main content viewport */ +#page-viewport { + --scrollbar-thumb: var(--overlay0); + --scrollbar-track: var(--base); + scrollbar-width: normal; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + +/* Multi-select dropdowns */ +.multi-select-dropdown { + --scrollbar-thumb: var(--surface2); + --scrollbar-track: var(--base); scrollbar-width: thin; - scrollbar-color: var(--surface1) var(--mantle); + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); } -/* Webkit browsers (Chrome, Safari, Edge) */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: var(--mantle); - border-radius: 4px; -} - -::-webkit-scrollbar-thumb { - background: var(--surface1); - border-radius: 4px; - border: 2px solid var(--mantle); -} - -::-webkit-scrollbar-thumb:hover { - background: var(--surface2); -} - -::-webkit-scrollbar-thumb:active { - background: var(--overlay0); -} - -/* Specific styling for multi-select dropdowns */ -.multi-select-dropdown::-webkit-scrollbar { - width: 6px; -} - -.multi-select-dropdown::-webkit-scrollbar-track { - background: var(--base); - border-radius: 3px; -} - -.multi-select-dropdown::-webkit-scrollbar-thumb { - background: var(--surface2); - border-radius: 3px; - border: 1px solid var(--base); -} - -.multi-select-dropdown::-webkit-scrollbar-thumb:hover { - background: var(--overlay0); -} - -/* Specific styling for modal content */ -.modal-scrollable::-webkit-scrollbar { - width: 8px; -} - -.modal-scrollable::-webkit-scrollbar-track { - background: var(--base); -} - -.modal-scrollable::-webkit-scrollbar-thumb { - background: var(--surface1); - border-radius: 4px; -} - -.modal-scrollable::-webkit-scrollbar-thumb:hover { - background: var(--surface2); +/* Modal content */ +.modal-scrollable { + --scrollbar-thumb: var(--surface1); + --scrollbar-track: var(--base); + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); } diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 555f0fd..8ef9548 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -2460,56 +2460,23 @@ font-weight: 700; font-style: italic; } -* { +#page-viewport { + --scrollbar-thumb: var(--overlay0); + --scrollbar-track: var(--base); + scrollbar-width: normal; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} +.multi-select-dropdown { + --scrollbar-thumb: var(--surface2); + --scrollbar-track: var(--base); scrollbar-width: thin; - scrollbar-color: var(--surface1) var(--mantle); + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); } -::-webkit-scrollbar { - width: 8px; - height: 8px; -} -::-webkit-scrollbar-track { - background: var(--mantle); - border-radius: 4px; -} -::-webkit-scrollbar-thumb { - background: var(--surface1); - border-radius: 4px; - border: 2px solid var(--mantle); -} -::-webkit-scrollbar-thumb:hover { - background: var(--surface2); -} -::-webkit-scrollbar-thumb:active { - background: var(--overlay0); -} -.multi-select-dropdown::-webkit-scrollbar { - width: 6px; -} -.multi-select-dropdown::-webkit-scrollbar-track { - background: var(--base); - border-radius: 3px; -} -.multi-select-dropdown::-webkit-scrollbar-thumb { - background: var(--surface2); - border-radius: 3px; - border: 1px solid var(--base); -} -.multi-select-dropdown::-webkit-scrollbar-thumb:hover { - background: var(--overlay0); -} -.modal-scrollable::-webkit-scrollbar { - width: 8px; -} -.modal-scrollable::-webkit-scrollbar-track { - background: var(--base); -} -.modal-scrollable::-webkit-scrollbar-thumb { - background: var(--surface1); - border-radius: 4px; -} -.modal-scrollable::-webkit-scrollbar-thumb:hover { - background: var(--surface2); +.modal-scrollable { + --scrollbar-thumb: var(--surface1); + --scrollbar-track: var(--base); + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); } @property --tw-translate-x { syntax: "*"; diff --git a/internal/view/baseview/footer.templ b/internal/view/baseview/footer.templ index 3347c50..bcb50d9 100644 --- a/internal/view/baseview/footer.templ +++ b/internal/view/baseview/footer.templ @@ -30,10 +30,10 @@ templ Footer() { templ backToTopButton() { } diff --git a/internal/view/baseview/layout.templ b/internal/view/baseview/layout.templ index 96a0c3c..531c87d 100644 --- a/internal/view/baseview/layout.templ +++ b/internal/view/baseview/layout.templ @@ -29,7 +29,7 @@ templ Layout(title string) { } @@ -38,16 +38,20 @@ templ Layout(title string) { @popup.ConfirmModal()
    @Navbar() if previewRole != nil { @previewModeBanner(previewRole) } -
    - { children... } +
    +
    +
    + { children... } +
    + @Footer() +
    - @Footer()
    From 1194d46613bd2199d260e22a2c69df88a980282f Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sat, 7 Mar 2026 13:06:58 +1100 Subject: [PATCH 20/33] navbar tweaks --- internal/view/baseview/navbar.templ | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/view/baseview/navbar.templ b/internal/view/baseview/navbar.templ index 1ac4c42..90977d7 100644 --- a/internal/view/baseview/navbar.templ +++ b/internal/view/baseview/navbar.templ @@ -240,6 +240,7 @@ templ mobileNav(navItems []NavItem, user *db.User) {
    From 04389970aca84cc38c431cd5444ba83ef3ba3968 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sat, 7 Mar 2026 13:13:09 +1100 Subject: [PATCH 21/33] stats page layout tweaks --- internal/embedfs/web/css/output.css | 8 +++++ .../seasonsview/season_league_stats.templ | 36 +++++++++++-------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 8ef9548..b0677f9 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -720,6 +720,9 @@ .items-start { align-items: flex-start; } + .items-stretch { + align-items: stretch; + } .justify-between { justify-content: space-between; } @@ -2316,6 +2319,11 @@ display: flex; } } + .lg\:w-auto { + @media (width >= 64rem) { + width: auto; + } + } .lg\:grid-cols-2 { @media (width >= 64rem) { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/internal/view/seasonsview/season_league_stats.templ b/internal/view/seasonsview/season_league_stats.templ index 16fad83..74c6ace 100644 --- a/internal/view/seasonsview/season_league_stats.templ +++ b/internal/view/seasonsview/season_league_stats.templ @@ -37,16 +37,16 @@ templ SeasonLeagueStats( if len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 {

    Trophy Leaders

    - -
    - -
    - @topGoalScorersTable(season, league, topGoals) - @topAssistersTable(season, league, topAssists) -
    - - @topSaversTable(season, league, topSaves) + +
    + +
    + @topGoalScorersTable(season, league, topGoals) + @topAssistersTable(season, league, topAssists)
    + + @topSaversTable(season, league, topSaves) +
    } @@ -61,7 +61,7 @@ templ SeasonLeagueStats( } templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.LeagueTopGoalScorer) { -
    +

    Top Goal Scorers @@ -79,7 +79,8 @@ templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.Leag

    No goal data available yet.

    } else { - +
    +
    @@ -109,12 +110,13 @@ templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.Leag }
    #
    +
    }
    } templ topAssistersTable(season *db.Season, league *db.League, assists []*db.LeagueTopAssister) { -
    +

    Top Assisters @@ -132,7 +134,8 @@ templ topAssistersTable(season *db.Season, league *db.League, assists []*db.Leag

    No assist data available yet.

    } else { - +
    +
    @@ -162,12 +165,13 @@ templ topAssistersTable(season *db.Season, league *db.League, assists []*db.Leag }
    #
    +
    }
    } templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTopSaver) { -
    +

    Top Saves @@ -185,7 +189,8 @@ templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTop

    No save data available yet.

    } else { - +
    +
    @@ -215,6 +220,7 @@ templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTop }
    #
    +
    }
    } From 78db8d03243d237a7bb40b4e016457305d11fd16 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sat, 7 Mar 2026 13:24:36 +1100 Subject: [PATCH 22/33] added staging banner --- internal/config/flags.go | 3 +++ internal/contexts/devmode.go | 1 + internal/embedfs/web/css/output.css | 5 ----- internal/server/middleware.go | 22 +++++++++++++--------- internal/view/baseview/layout.templ | 9 +++++++++ 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/internal/config/flags.go b/internal/config/flags.go index b131a67..986b795 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -14,6 +14,7 @@ type Flags struct { GenEnv string EnvFile string DevMode bool + Staging bool // Database reset (destructive) ResetDB bool @@ -36,6 +37,7 @@ func SetupFlags() (*Flags, error) { genEnv := flag.String("genenv", "", "Generate a .env file with all environment variables (specify filename)") envfile := flag.String("envfile", ".env", "Specify a .env file to use for the configuration") devMode := flag.Bool("dev", false, "Run the server in dev mode") + staging := flag.Bool("staging", false, "Show a staging banner") // Database reset (destructive) resetDB := flag.Bool("reset-db", false, "โš ๏ธ DESTRUCTIVE: Drop and recreate all tables (dev only)") @@ -92,6 +94,7 @@ func SetupFlags() (*Flags, error) { GenEnv: *genEnv, EnvFile: *envfile, DevMode: *devMode, + Staging: *staging, ResetDB: *resetDB, MigrateUp: *migrateUp, MigrateRollback: *migrateRollback, diff --git a/internal/contexts/devmode.go b/internal/contexts/devmode.go index ea1c20e..21827ac 100644 --- a/internal/contexts/devmode.go +++ b/internal/contexts/devmode.go @@ -13,4 +13,5 @@ func DevMode(ctx context.Context) DevInfo { type DevInfo struct { WebsocketBase string HTMXLog bool + StagingBanner bool } diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index b0677f9..59614a2 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -2344,11 +2344,6 @@ align-items: flex-end; } } - .lg\:items-start { - @media (width >= 64rem) { - align-items: flex-start; - } - } .lg\:justify-between { @media (width >= 64rem) { justify-content: space-between; diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 21f3f28..c75abcc 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -44,17 +44,21 @@ func addMiddleware( func devMode(cfg *config.Config) hws.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if cfg.Flags.DevMode { - devInfo := contexts.DevInfo{ - WebsocketBase: "ws://" + cfg.HWS.Host + ":" + strconv.FormatUint(cfg.HWS.Port, 10), - HTMXLog: true, - } - ctx := context.WithValue(r.Context(), contexts.DevModeKey, devInfo) - req := r.WithContext(ctx) - next.ServeHTTP(w, req) + if !cfg.Flags.DevMode && !cfg.Flags.Staging { + next.ServeHTTP(w, r) return } - next.ServeHTTP(w, r) + devInfo := contexts.DevInfo{} + if cfg.Flags.DevMode { + devInfo.WebsocketBase = "ws://" + cfg.HWS.Host + ":" + strconv.FormatUint(cfg.HWS.Port, 10) + devInfo.HTMXLog = true + } + if cfg.Flags.Staging { + devInfo.StagingBanner = true + } + ctx := context.WithValue(r.Context(), contexts.DevModeKey, devInfo) + req := r.WithContext(ctx) + next.ServeHTTP(w, req) }, ) } diff --git a/internal/view/baseview/layout.templ b/internal/view/baseview/layout.templ index 531c87d..c118543 100644 --- a/internal/view/baseview/layout.templ +++ b/internal/view/baseview/layout.templ @@ -40,6 +40,9 @@ templ Layout(title string) { id="main-content" class="flex flex-col h-screen" > + if devInfo.StagingBanner { + @stagingBanner() + } @Navbar() if previewRole != nil { @previewModeBanner(previewRole) @@ -57,6 +60,12 @@ templ Layout(title string) { } +templ stagingBanner() { +
    + Staging Environment - For Testing Only +
    +} + // Preview mode banner (private helper) templ previewModeBanner(previewRole *db.Role) {
    From dd1ed61adb24c7d24ab2c43af598c9365b4a15f4 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sat, 7 Mar 2026 14:18:06 +1100 Subject: [PATCH 23/33] home page added --- internal/embedfs/web/css/output.css | 23 ++- internal/handlers/index.go | 67 +++++++- internal/server/routes.go | 2 +- internal/view/homeview/external_links.templ | 33 ++++ internal/view/homeview/index_page.templ | 29 +++- internal/view/homeview/latest_standings.templ | 151 ++++++++++++++++++ 6 files changed, 294 insertions(+), 11 deletions(-) create mode 100644 internal/view/homeview/external_links.templ create mode 100644 internal/view/homeview/latest_standings.templ diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 59614a2..d44edbf 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -20,6 +20,7 @@ --container-3xl: 48rem; --container-4xl: 56rem; --container-5xl: 64rem; + --container-6xl: 72rem; --container-7xl: 80rem; --text-xs: 0.75rem; --text-xs--line-height: calc(1 / 0.75); @@ -371,9 +372,6 @@ .mt-24 { margin-top: calc(var(--spacing) * 24); } - .mt-25 { - margin-top: calc(var(--spacing) * 25); - } .mt-auto { margin-top: auto; } @@ -395,6 +393,9 @@ .mb-8 { margin-bottom: calc(var(--spacing) * 8); } + .mb-12 { + margin-bottom: calc(var(--spacing) * 12); + } .mb-auto { margin-bottom: auto; } @@ -582,6 +583,9 @@ .max-w-5xl { max-width: var(--container-5xl); } + .max-w-6xl { + max-width: var(--container-6xl); + } .max-w-7xl { max-width: var(--container-7xl); } @@ -711,6 +715,9 @@ .place-content-center { place-content: center; } + .items-baseline { + align-items: baseline; + } .items-center { align-items: center; } @@ -1399,6 +1406,9 @@ .whitespace-pre-wrap { white-space: pre-wrap; } + .text-base { + color: var(--base); + } .text-blue { color: var(--blue); } @@ -1578,6 +1588,13 @@ } } } + .group-hover\:text-blue { + &:is(:where(.group):hover *) { + @media (hover: hover) { + color: var(--blue); + } + } + } .group-hover\:opacity-100 { &:is(:where(.group):hover *) { @media (hover: hover) { diff --git a/internal/handlers/index.go b/internal/handlers/index.go index 6eaa23a..f647a9b 100644 --- a/internal/handlers/index.go +++ b/internal/handlers/index.go @@ -1,22 +1,85 @@ package handlers import ( + "context" "net/http" "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/throw" homeview "git.haelnorr.com/h/oslstats/internal/view/homeview" + "github.com/pkg/errors" + "github.com/uptrace/bun" ) // Index handles responses to the / path. Also serves a 404 Page for paths that // don't have explicit handlers -func Index(s *hws.Server) http.Handler { +func Index(s *hws.Server, conn *db.DB) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { throw.NotFound(s, w, r, r.URL.Path) + return } - renderSafely(homeview.IndexPage(), s, r, w) + + var season *db.Season + var standings []homeview.LeagueStandings + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + // Get the most recent season + seasons, err := db.ListSeasons(ctx, tx, &db.PageOpts{ + Page: 1, + PerPage: 1, + Order: bun.OrderDesc, + OrderBy: "start_date", + }) + if err != nil { + return false, errors.Wrap(err, "db.ListSeasons") + } + + if seasons.Total == 0 || len(seasons.Items) == 0 { + return true, nil + } + + season = seasons.Items[0] + + // Build leaderboards for each league in this season + standings = make([]homeview.LeagueStandings, 0, len(season.Leagues)) + for _, league := range season.Leagues { + _, l, teams, err := db.GetSeasonLeagueWithTeams(ctx, tx, season.ShortName, league.ShortName) + if err != nil { + return false, errors.Wrap(err, "db.GetSeasonLeagueWithTeams") + } + + fixtures, err := db.GetAllocatedFixtures(ctx, tx, season.ID, l.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) + + standings = append(standings, homeview.LeagueStandings{ + League: l, + Leaderboard: leaderboard, + }) + } + + return true, nil + }); !ok { + return + } + + renderSafely(homeview.IndexPage(season, standings), s, r, w) }, ) } diff --git a/internal/server/routes.go b/internal/server/routes.go index cda9e57..b7afe09 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -39,7 +39,7 @@ func addRoutes( { Path: "/", Method: hws.MethodGET, - Handler: handlers.Index(s), + Handler: handlers.Index(s, conn), }, } diff --git a/internal/view/homeview/external_links.templ b/internal/view/homeview/external_links.templ new file mode 100644 index 0000000..9f08eab --- /dev/null +++ b/internal/view/homeview/external_links.templ @@ -0,0 +1,33 @@ +package homeview + +// ExternalLinks renders card tiles for external community resources +templ ExternalLinks() { + +} diff --git a/internal/view/homeview/index_page.templ b/internal/view/homeview/index_page.templ index 452aa5b..66cfc71 100644 --- a/internal/view/homeview/index_page.templ +++ b/internal/view/homeview/index_page.templ @@ -1,13 +1,32 @@ package homeview +import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/view/baseview" // Page content for the index page -templ IndexPage() { - @baseview.Layout("OSL Stats") { -
    -
    OSL Stats
    -
    Placeholder text
    +templ IndexPage(season *db.Season, standings []LeagueStandings) { + @baseview.Layout("Oceanic Slapshot League") { +
    +
    +

    + Oceanic Slapshot League +

    +
    +

    + The Oceanic Slapshot League (OSL) is a community for casual and competitive play of Slapshot: Rebound. + It is managed by a small group of community members, and aims to provide a place for players in the Oceanic + region (primarily Australia and New Zealand) to compete and play in organised League competitions, as well as + casual pick-up games (RPUGs) and public matches (in-game matchmaking). + The league is open to everyone, regardless of skill level. +

    +
    +
    +
    + @LatestStandings(season, standings) +
    +
    + @ExternalLinks() +
    } } diff --git a/internal/view/homeview/latest_standings.templ b/internal/view/homeview/latest_standings.templ new file mode 100644 index 0000000..c99825e --- /dev/null +++ b/internal/view/homeview/latest_standings.templ @@ -0,0 +1,151 @@ +package homeview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" + +// LeagueStandings holds the data needed to render a single league's table +type LeagueStandings struct { + League *db.League + Leaderboard []*db.LeaderboardEntry +} + +// LatestStandings renders the latest standings section with tabs to switch +// between leagues from the most recent season +templ LatestStandings(season *db.Season, standings []LeagueStandings) { +
    +
    +

    Latest Standings

    + if season != nil { + + { season.Name } + + } +
    + if season == nil || len(standings) == 0 { +
    +

    No standings available yet.

    +
    + } else { +
    + if len(standings) > 1 { +
    + for _, s := range standings { + + } +
    + } + for _, s := range standings { +
    + @standingsTable(season, s.League, s.Leaderboard) +
    + } +
    + } +
    +} + +templ standingsTable(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) { + if len(leaderboard) == 0 { +
    +

    No teams in this league yet.

    +
    + } else { +
    +
    + Points: + W = { fmt.Sprint(db.PointsWin) } + OTW = { fmt.Sprint(db.PointsOvertimeWin) } + OTL = { fmt.Sprint(db.PointsOvertimeLoss) } + L = { fmt.Sprint(db.PointsLoss) } +
    +
    + + + + + + + + + + + + + + + + + + for _, entry := range leaderboard { + @standingsRow(entry, season, league) + } + +
    #TeamGPWOTWOTLLGFGAGDPTS
    +
    +
    + } +} + +templ standingsRow(entry *db.LeaderboardEntry, season *db.Season, league *db.League) { + {{ + r := entry.Record + goalDiff := r.GoalsFor - r.GoalsAgainst + var gdStr string + if goalDiff > 0 { + gdStr = fmt.Sprintf("+%d", goalDiff) + } else { + gdStr = fmt.Sprint(goalDiff) + } + }} + + + { fmt.Sprint(entry.Position) } + + + @links.TeamLinkInSeason(entry.Team, season, league) + + + { fmt.Sprint(r.Played) } + + + { fmt.Sprint(r.Wins) } + + + { fmt.Sprint(r.OvertimeWins) } + + + { fmt.Sprint(r.OvertimeLosses) } + + + { fmt.Sprint(r.Losses) } + + + { fmt.Sprint(r.GoalsFor) } + + + { fmt.Sprint(r.GoalsAgainst) } + + + if goalDiff > 0 { + { gdStr } + } else if goalDiff < 0 { + { gdStr } + } else { + { gdStr } + } + + + { fmt.Sprint(r.Points) } + + +} From 14e10d095e98f34aa24450061714979c470ef637 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sat, 7 Mar 2026 14:23:05 +1100 Subject: [PATCH 24/33] removed placeholders from footer --- internal/embedfs/web/css/output.css | 41 +++++++++++++++++++++++++++++ internal/view/baseview/footer.templ | 7 ++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index d44edbf..3184498 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -267,6 +267,9 @@ .top-0 { top: calc(var(--spacing) * 0); } + .top-1 { + top: calc(var(--spacing) * 1); + } .top-1\/2 { top: calc(1 / 2 * 100%); } @@ -336,6 +339,9 @@ .-mt-3 { margin-top: calc(var(--spacing) * -3); } + .mt-0 { + margin-top: calc(var(--spacing) * 0); + } .mt-0\.5 { margin-top: calc(var(--spacing) * 0.5); } @@ -369,6 +375,9 @@ .mt-12 { margin-top: calc(var(--spacing) * 12); } + .mt-16 { + margin-top: calc(var(--spacing) * 16); + } .mt-24 { margin-top: calc(var(--spacing) * 24); } @@ -625,12 +634,22 @@ .flex-1 { flex: 1; } + .flex-shrink { + flex-shrink: 1; + } .flex-shrink-0 { flex-shrink: 0; } .shrink-0 { flex-shrink: 0; } + .border-collapse { + border-collapse: collapse; + } + .-translate-y-1 { + --tw-translate-y: calc(var(--spacing) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -739,6 +758,9 @@ .justify-end { justify-content: flex-end; } + .gap-0 { + gap: calc(var(--spacing) * 0); + } .gap-0\.5 { gap: calc(var(--spacing) * 0.5); } @@ -970,6 +992,9 @@ .border-overlay0 { border-color: var(--overlay0); } + .border-peach { + border-color: var(--peach); + } .border-peach\/50 { border-color: var(--peach); @supports (color: color-mix(in lab, red, red)) { @@ -1087,6 +1112,9 @@ .bg-mauve { background-color: var(--mauve); } + .bg-overlay0 { + background-color: var(--overlay0); + } .bg-overlay0\/10 { background-color: var(--overlay0); @supports (color: color-mix(in lab, red, red)) { @@ -1234,6 +1262,9 @@ .px-6 { padding-inline: calc(var(--spacing) * 6); } + .py-0 { + padding-block: calc(var(--spacing) * 0); + } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } @@ -1526,6 +1557,10 @@ --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); @@ -2650,6 +2685,11 @@ inherits: false; initial-value: 0 0 #0000; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @property --tw-blur { syntax: "*"; inherits: false; @@ -2752,6 +2792,7 @@ --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; + --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; diff --git a/internal/view/baseview/footer.templ b/internal/view/baseview/footer.templ index bcb50d9..56ecf2b 100644 --- a/internal/view/baseview/footer.templ +++ b/internal/view/baseview/footer.templ @@ -56,12 +56,9 @@ templ backToTopButton() { templ footerBranding() {
    -
    +
    OSL Stats
    -

    - placeholder text -

    } @@ -86,7 +83,7 @@ templ footerLinks(items []FooterItem) { templ footerCopyright() {

    - by Haelnorr | placeholder text + by Haelnorr

    } From 34cba6a81fcc17bc44f7c0bb0ac1603a47ea2889 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sat, 7 Mar 2026 14:33:13 +1100 Subject: [PATCH 25/33] added kofi link --- internal/view/baseview/footer.templ | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/view/baseview/footer.templ b/internal/view/baseview/footer.templ index 56ecf2b..a3415be 100644 --- a/internal/view/baseview/footer.templ +++ b/internal/view/baseview/footer.templ @@ -26,6 +26,15 @@ templ Footer() {
    + + } templ backToTopButton() { From 1cab39a4f704bbe64ec0a494fe74945a5f246ad7 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 8 Mar 2026 18:12:03 +1100 Subject: [PATCH 26/33] finals generation added --- .../migrations/20260308140000_add_playoffs.go | 98 ++++ internal/db/playoff.go | 326 +++++++++++ internal/db/playoff_generation.go | 531 ++++++++++++++++++ internal/db/setup.go | 3 + internal/embedfs/web/css/output.css | 63 +-- internal/handlers/season_league_finals.go | 273 ++++++++- internal/permissions/constants.go | 3 + internal/server/routes.go | 10 + .../view/seasonsview/finals_setup_form.templ | 268 +++++++++ .../view/seasonsview/playoff_bracket.templ | 165 ++++++ internal/view/seasonsview/playoff_helpers.go | 88 +++ .../seasonsview/season_league_finals.templ | 54 +- justfile | 85 +++ 13 files changed, 1916 insertions(+), 51 deletions(-) create mode 100644 internal/db/migrations/20260308140000_add_playoffs.go create mode 100644 internal/db/playoff.go create mode 100644 internal/db/playoff_generation.go create mode 100644 internal/view/seasonsview/finals_setup_form.templ create mode 100644 internal/view/seasonsview/playoff_bracket.templ create mode 100644 internal/view/seasonsview/playoff_helpers.go diff --git a/internal/db/migrations/20260308140000_add_playoffs.go b/internal/db/migrations/20260308140000_add_playoffs.go new file mode 100644 index 0000000..591d941 --- /dev/null +++ b/internal/db/migrations/20260308140000_add_playoffs.go @@ -0,0 +1,98 @@ +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 { + // Create playoff_brackets table + _, err := conn.NewCreateTable(). + Model((*db.PlayoffBracket)(nil)). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Create playoff_series table + _, err = conn.NewCreateTable(). + Model((*db.PlayoffSeries)(nil)). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Create playoff_matches table + _, err = conn.NewCreateTable(). + Model((*db.PlayoffMatch)(nil)). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Add foreign key for winner_next_series_id + _, err = conn.NewRaw(` + ALTER TABLE playoff_series + ADD CONSTRAINT fk_winner_next_series + FOREIGN KEY (winner_next_series_id) REFERENCES playoff_series(id) + ON DELETE SET NULL + `).Exec(ctx) + if err != nil { + return err + } + + // Add foreign key for loser_next_series_id + _, err = conn.NewRaw(` + ALTER TABLE playoff_series + ADD CONSTRAINT fk_loser_next_series + FOREIGN KEY (loser_next_series_id) REFERENCES playoff_series(id) + ON DELETE SET NULL + `).Exec(ctx) + if err != nil { + return err + } + + return nil + }, + // DOWN migration + func(ctx context.Context, conn *bun.DB) error { + // Drop tables in reverse order (respecting foreign keys) + _, err := conn.NewDropTable(). + Model((*db.PlayoffMatch)(nil)). + IfExists(). + Cascade(). + Exec(ctx) + if err != nil { + return err + } + + _, err = conn.NewDropTable(). + Model((*db.PlayoffSeries)(nil)). + IfExists(). + Cascade(). + Exec(ctx) + if err != nil { + return err + } + + _, err = conn.NewDropTable(). + Model((*db.PlayoffBracket)(nil)). + IfExists(). + Cascade(). + Exec(ctx) + if err != nil { + return err + } + + return nil + }, + ) +} diff --git a/internal/db/playoff.go b/internal/db/playoff.go new file mode 100644 index 0000000..01a2bdd --- /dev/null +++ b/internal/db/playoff.go @@ -0,0 +1,326 @@ +package db + +import ( + "context" + "time" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// PlayoffFormat represents the bracket format based on team count +type PlayoffFormat string + +const ( + // PlayoffFormat5to6 is for 5-6 teams: top 5 qualify, double-elimination style + PlayoffFormat5to6 PlayoffFormat = "5-6-teams" + // PlayoffFormat7to9 is for 7-9 teams: top 6 qualify, seeded bracket + PlayoffFormat7to9 PlayoffFormat = "7-9-teams" + // PlayoffFormat10to15 is for 10-15 teams: top 8 qualify + PlayoffFormat10to15 PlayoffFormat = "10-15-teams" +) + +// PlayoffStatus represents the current state of a playoff bracket +type PlayoffStatus string + +const ( + PlayoffStatusUpcoming PlayoffStatus = "upcoming" + PlayoffStatusInProgress PlayoffStatus = "in_progress" + PlayoffStatusCompleted PlayoffStatus = "completed" +) + +// SeriesStatus represents the current state of a playoff series +type SeriesStatus string + +const ( + SeriesStatusPending SeriesStatus = "pending" + SeriesStatusInProgress SeriesStatus = "in_progress" + SeriesStatusCompleted SeriesStatus = "completed" + SeriesStatusBye SeriesStatus = "bye" +) + +// PlayoffBracket is the top-level container for a league's playoff bracket +type PlayoffBracket struct { + bun.BaseModel `bun:"table:playoff_brackets,alias:pb"` + + ID int `bun:"id,pk,autoincrement"` + SeasonID int `bun:",notnull,unique:season_league"` + LeagueID int `bun:",notnull,unique:season_league"` + Format PlayoffFormat `bun:",notnull"` + Status PlayoffStatus `bun:",notnull,default:'upcoming'"` + CreatedAt int64 `bun:",notnull"` + UpdatedAt *int64 `bun:"updated_at"` + + Season *Season `bun:"rel:belongs-to,join:season_id=id"` + League *League `bun:"rel:belongs-to,join:league_id=id"` + Series []*PlayoffSeries `bun:"rel:has-many,join:id=bracket_id"` +} + +// PlayoffSeries represents a single matchup (potentially best-of-N) in the bracket +type PlayoffSeries struct { + bun.BaseModel `bun:"table:playoff_series,alias:ps"` + + ID int `bun:"id,pk,autoincrement"` + BracketID int `bun:",notnull"` + SeriesNumber int `bun:",notnull"` // Display order within bracket + Round string `bun:",notnull"` // e.g. "qualifying_final", "semi_final", "grand_final" + Label string `bun:",notnull"` // Human-readable label e.g. "QF1", "SF2", "Grand Final" + Team1ID *int `bun:"team1_id"` + Team2ID *int `bun:"team2_id"` + Team1Seed *int `bun:"team1_seed"` // Original seeding position (1st, 2nd, etc.) + Team2Seed *int `bun:"team2_seed"` // Original seeding position + WinnerTeamID *int `bun:"winner_team_id"` // Set when series is decided + LoserTeamID *int `bun:"loser_team_id"` // Set when series is decided + MatchesToWin int `bun:",notnull,default:1"` // 1 = single match, 2 = Bo3, 3 = Bo5, etc. + Team1Wins int `bun:",notnull,default:0"` // Matches won by team1 + Team2Wins int `bun:",notnull,default:0"` // Matches won by team2 + Status SeriesStatus `bun:",notnull,default:'pending'"` // pending, in_progress, completed, bye + WinnerNextID *int `bun:"winner_next_series_id"` // Series the winner advances to + WinnerNextSlot *string `bun:"winner_next_slot"` // "team1" or "team2" in next series + LoserNextID *int `bun:"loser_next_series_id"` // Series the loser drops to (double-elim) + LoserNextSlot *string `bun:"loser_next_slot"` // "team1" or "team2" in loser's next series + CreatedAt int64 `bun:",notnull"` + + Bracket *PlayoffBracket `bun:"rel:belongs-to,join:bracket_id=id"` + Team1 *Team `bun:"rel:belongs-to,join:team1_id=id"` + Team2 *Team `bun:"rel:belongs-to,join:team2_id=id"` + Winner *Team `bun:"rel:belongs-to,join:winner_team_id=id"` + Loser *Team `bun:"rel:belongs-to,join:loser_team_id=id"` + Matches []*PlayoffMatch `bun:"rel:has-many,join:id=series_id"` +} + +// PlayoffMatch represents a single game within a series +type PlayoffMatch struct { + bun.BaseModel `bun:"table:playoff_matches,alias:pm"` + + ID int `bun:"id,pk,autoincrement"` + SeriesID int `bun:",notnull"` + MatchNumber int `bun:",notnull"` // 1-indexed: game 1, game 2, etc. + HomeTeamID *int `bun:"home_team_id"` + AwayTeamID *int `bun:"away_team_id"` + FixtureID *int `bun:"fixture_id"` // Links to existing fixture system + Status string `bun:",notnull,default:'pending'"` + CreatedAt int64 `bun:",notnull"` + + Series *PlayoffSeries `bun:"rel:belongs-to,join:series_id=id"` + Home *Team `bun:"rel:belongs-to,join:home_team_id=id"` + Away *Team `bun:"rel:belongs-to,join:away_team_id=id"` + Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"` +} + +// NewPlayoffBracket creates a new playoff bracket for a season+league +func NewPlayoffBracket( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, + format PlayoffFormat, + audit *AuditMeta, +) (*PlayoffBracket, error) { + bracket := &PlayoffBracket{ + SeasonID: seasonID, + LeagueID: leagueID, + Format: format, + Status: PlayoffStatusUpcoming, + CreatedAt: time.Now().Unix(), + } + err := Insert(tx, bracket).WithAudit(audit, &AuditInfo{ + Action: "playoffs.create_bracket", + ResourceType: "playoff_bracket", + ResourceID: nil, + Details: map[string]any{ + "season_id": seasonID, + "league_id": leagueID, + "format": string(format), + }, + }).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "Insert") + } + return bracket, nil +} + +// GetPlayoffBracket retrieves a playoff bracket for a season+league with all series and teams +func GetPlayoffBracket( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, +) (*PlayoffBracket, error) { + bracket := new(PlayoffBracket) + err := tx.NewSelect(). + Model(bracket). + Where("pb.season_id = ?", seasonID). + Where("pb.league_id = ?", leagueID). + Relation("Season"). + Relation("League"). + Relation("Series", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Order("ps.series_number ASC") + }). + Relation("Series.Team1"). + Relation("Series.Team2"). + Relation("Series.Winner"). + Relation("Series.Loser"). + Relation("Series.Matches", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Order("pm.match_number ASC") + }). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil, nil + } + return nil, errors.Wrap(err, "tx.NewSelect") + } + return bracket, nil +} + +// GetPlayoffBracketByID retrieves a playoff bracket by ID with all series +func GetPlayoffBracketByID( + ctx context.Context, + tx bun.Tx, + bracketID int, +) (*PlayoffBracket, error) { + return GetByID[PlayoffBracket](tx, bracketID). + Relation("Season"). + Relation("League"). + Relation("Series", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Order("ps.series_number ASC") + }). + Relation("Series.Team1"). + Relation("Series.Team2"). + Relation("Series.Winner"). + Relation("Series.Loser"). + Relation("Series.Matches", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Order("pm.match_number ASC") + }). + Get(ctx) +} + +// NewPlayoffSeries creates a new series within a bracket +func NewPlayoffSeries( + ctx context.Context, + tx bun.Tx, + bracket *PlayoffBracket, + seriesNumber int, + round, label string, + team1ID, team2ID *int, + team1Seed, team2Seed *int, + matchesToWin int, + status SeriesStatus, +) (*PlayoffSeries, error) { + series := &PlayoffSeries{ + BracketID: bracket.ID, + SeriesNumber: seriesNumber, + Round: round, + Label: label, + Team1ID: team1ID, + Team2ID: team2ID, + Team1Seed: team1Seed, + Team2Seed: team2Seed, + MatchesToWin: matchesToWin, + Status: status, + CreatedAt: time.Now().Unix(), + } + err := Insert(tx, series).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "Insert") + } + return series, nil +} + +// SetSeriesAdvancement sets the advancement links for a series +func SetSeriesAdvancement( + ctx context.Context, + tx bun.Tx, + seriesID int, + winnerNextID *int, + winnerNextSlot *string, + loserNextID *int, + loserNextSlot *string, +) error { + _, err := tx.NewUpdate(). + Model((*PlayoffSeries)(nil)). + Set("winner_next_series_id = ?", winnerNextID). + Set("winner_next_slot = ?", winnerNextSlot). + Set("loser_next_series_id = ?", loserNextID). + Set("loser_next_slot = ?", loserNextSlot). + Where("id = ?", seriesID). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "tx.NewUpdate") + } + return nil +} + +// CountUnplayedFixtures counts fixtures without finalized results for a season+league +func CountUnplayedFixtures( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, +) (int, error) { + count, err := tx.NewSelect(). + Model((*Fixture)(nil)). + Where("f.season_id = ?", seasonID). + Where("f.league_id = ?", leagueID). + Where("f.game_week IS NOT NULL"). + Where("NOT EXISTS (SELECT 1 FROM fixture_results fr WHERE fr.fixture_id = f.id AND fr.finalized = true)"). + Count(ctx) + if err != nil { + return 0, errors.Wrap(err, "tx.NewSelect.Count") + } + return count, nil +} + +// GetUnplayedFixtures returns all fixtures without finalized results for a season+league +func GetUnplayedFixtures( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, +) ([]*Fixture, error) { + fixtures, err := GetList[Fixture](tx). + Where("f.season_id = ?", seasonID). + Where("f.league_id = ?", leagueID). + Where("f.game_week IS NOT NULL"). + Where("NOT EXISTS (SELECT 1 FROM fixture_results fr WHERE fr.fixture_id = f.id AND fr.finalized = true)"). + Order("f.game_week ASC", "f.round ASC", "f.id ASC"). + Relation("HomeTeam"). + Relation("AwayTeam"). + GetAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "GetList") + } + return fixtures, nil +} + +// AutoForfeitUnplayedFixtures creates mutual forfeit results for all unplayed fixtures +func AutoForfeitUnplayedFixtures( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, + userID int, + audit *AuditMeta, +) (int, error) { + unplayed, err := GetUnplayedFixtures(ctx, tx, seasonID, leagueID) + if err != nil { + return 0, errors.Wrap(err, "GetUnplayedFixtures") + } + + reason := "Auto-forfeited: regular season ended for finals" + for _, fixture := range unplayed { + // Check if a result already exists (non-finalized) + existing, err := GetFixtureResult(ctx, tx, fixture.ID) + if err != nil { + return 0, errors.Wrap(err, "GetFixtureResult") + } + if existing != nil { + // Skip fixtures that already have any result + continue + } + + _, err = CreateForfeitResult(ctx, tx, fixture, + ForfeitTypeMutual, "", reason, userID, audit) + if err != nil { + return 0, errors.Wrap(err, "CreateForfeitResult") + } + } + + return len(unplayed), nil +} diff --git a/internal/db/playoff_generation.go b/internal/db/playoff_generation.go new file mode 100644 index 0000000..176cb55 --- /dev/null +++ b/internal/db/playoff_generation.go @@ -0,0 +1,531 @@ +package db + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// GeneratePlayoffBracket creates a complete bracket structure from the leaderboard. +// It creates the bracket, all series with advancement links, but no individual +// matches (those are created when results are recorded). +// roundFormats maps round names (e.g. "grand_final") to matches_to_win values +// (1 = BO1, 2 = BO3, 3 = BO5). Rounds not in the map default to BO1. +func GeneratePlayoffBracket( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, + format PlayoffFormat, + leaderboard []*LeaderboardEntry, + roundFormats map[string]int, + audit *AuditMeta, +) (*PlayoffBracket, error) { + // Validate format and team count + if err := validateFormatTeamCount(format, len(leaderboard)); err != nil { + return nil, err + } + + // Check no bracket already exists + existing, err := GetPlayoffBracket(ctx, tx, seasonID, leagueID) + if err != nil { + return nil, errors.Wrap(err, "GetPlayoffBracket") + } + if existing != nil { + return nil, BadRequest("playoff bracket already exists for this season and league") + } + + // Create the bracket + bracket, err := NewPlayoffBracket(ctx, tx, seasonID, leagueID, format, audit) + if err != nil { + return nil, errors.Wrap(err, "NewPlayoffBracket") + } + + // Generate series based on format + switch format { + case PlayoffFormat5to6: + err = generate5to6Bracket(ctx, tx, bracket, leaderboard, roundFormats) + case PlayoffFormat7to9: + err = generate7to9Bracket(ctx, tx, bracket, leaderboard, roundFormats) + case PlayoffFormat10to15: + err = generate10to15Bracket(ctx, tx, bracket, leaderboard, roundFormats) + default: + return nil, BadRequest(fmt.Sprintf("unknown playoff format: %s", format)) + } + if err != nil { + return nil, errors.Wrap(err, "generateBracket") + } + + return bracket, nil +} + +func validateFormatTeamCount(format PlayoffFormat, teamCount int) error { + switch format { + case PlayoffFormat5to6: + if teamCount < 5 { + return BadRequest( + fmt.Sprintf("5-6 team format requires at least 5 teams, got %d", teamCount)) + } + case PlayoffFormat7to9: + if teamCount < 7 { + return BadRequest( + fmt.Sprintf("7-9 team format requires at least 7 teams, got %d", teamCount)) + } + case PlayoffFormat10to15: + if teamCount < 10 { + return BadRequest( + fmt.Sprintf("10-15 team format requires at least 10 teams, got %d", teamCount)) + } + default: + return BadRequest(fmt.Sprintf("unknown playoff format: %s", format)) + } + return nil +} + +// intPtr is a helper to create a pointer to an int +func intPtr(i int) *int { + return &i +} + +// strPtr is a helper to create a pointer to a string +func strPtr(s string) *string { + return &s +} + +// getMatchesToWin looks up the matches_to_win value for a round from the config map. +// Returns 1 (BO1) if the round is not in the map or the value is invalid. +func getMatchesToWin(roundFormats map[string]int, round string) int { + if roundFormats == nil { + return 1 + } + if v, ok := roundFormats[round]; ok && v >= 1 && v <= 3 { + return v + } + return 1 +} + +// generate5to6Bracket creates: +// +// Round 1: +// S1 (Upper Bracket): 2nd vs 3rd +// S2 (Lower Bracket): 4th vs 5th +// Round 2: +// S3 (Upper Final): 1st vs Winner(S1) โ€” second chance for both +// S4 (Lower Final): Loser(S3) vs Winner(S2) +// Round 3: +// S5 (Grand Final): Winner(S3) vs Winner(S4) +func generate5to6Bracket( + ctx context.Context, + tx bun.Tx, + bracket *PlayoffBracket, + leaderboard []*LeaderboardEntry, + roundFormats map[string]int, +) error { + seed1 := leaderboard[0] + seed2 := leaderboard[1] + seed3 := leaderboard[2] + seed4 := leaderboard[3] + seed5 := leaderboard[4] + + // S1: Upper Bracket - 2nd vs 3rd + s1, err := NewPlayoffSeries(ctx, tx, bracket, 1, + "upper_bracket", "Upper Bracket", + &seed2.Team.ID, &seed3.Team.ID, + intPtr(2), intPtr(3), + getMatchesToWin(roundFormats, "upper_bracket"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create S1") + } + + // S2: Lower Bracket - 4th vs 5th (elimination) + s2, err := NewPlayoffSeries(ctx, tx, bracket, 2, + "lower_bracket", "Lower Bracket", + &seed4.Team.ID, &seed5.Team.ID, + intPtr(4), intPtr(5), + getMatchesToWin(roundFormats, "lower_bracket"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create S2") + } + + // S3: Upper Final - 1st vs Winner(S1) + s3, err := NewPlayoffSeries(ctx, tx, bracket, 3, + "upper_final", "Upper Final", + &seed1.Team.ID, nil, // team2 filled by S1 winner + intPtr(1), nil, + getMatchesToWin(roundFormats, "upper_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create S3") + } + + // S4: Lower Final - Loser(S3) vs Winner(S2) + s4, err := NewPlayoffSeries(ctx, tx, bracket, 4, + "lower_final", "Lower Final", + nil, nil, // team1 = Loser(S3), team2 = Winner(S2) + nil, nil, + getMatchesToWin(roundFormats, "lower_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create S4") + } + + // S5: Grand Final - Winner(S3) vs Winner(S4) + s5, err := NewPlayoffSeries(ctx, tx, bracket, 5, + "grand_final", "Grand Final", + nil, nil, + nil, nil, + getMatchesToWin(roundFormats, "grand_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create S5") + } + + // Wire up advancement + // S1: Winner -> S3 (team2), no loser advancement (eliminated) + err = SetSeriesAdvancement(ctx, tx, s1.ID, + &s3.ID, strPtr("team2"), nil, nil) + if err != nil { + return errors.Wrap(err, "wire S1") + } + + // S2: Winner -> S4 (team2), no loser advancement (eliminated) + err = SetSeriesAdvancement(ctx, tx, s2.ID, + &s4.ID, strPtr("team2"), nil, nil) + if err != nil { + return errors.Wrap(err, "wire S2") + } + + // S3: Winner -> S5 (team1), Loser -> S4 (team1) โ€” second chance + err = SetSeriesAdvancement(ctx, tx, s3.ID, + &s5.ID, strPtr("team1"), &s4.ID, strPtr("team1")) + if err != nil { + return errors.Wrap(err, "wire S3") + } + + // S4: Winner -> S5 (team2), no loser advancement (eliminated) + err = SetSeriesAdvancement(ctx, tx, s4.ID, + &s5.ID, strPtr("team2"), nil, nil) + if err != nil { + return errors.Wrap(err, "wire S4") + } + + // S5: Grand Final - no advancement + _ = s5 + + return nil +} + +// generate7to9Bracket creates: +// +// Quarter Finals: +// S1 (QF1): 3rd vs 6th +// S2 (QF2): 4th vs 5th +// Semi Finals: +// S3 (SF1): 1st vs Winner(S1) +// S4 (SF2): 2nd vs Winner(S2) +// Third Place Playoff: +// S5: Loser(S3) vs Loser(S4) +// Grand Final: +// S6: Winner(S3) vs Winner(S4) +func generate7to9Bracket( + ctx context.Context, + tx bun.Tx, + bracket *PlayoffBracket, + leaderboard []*LeaderboardEntry, + roundFormats map[string]int, +) error { + seed1 := leaderboard[0] + seed2 := leaderboard[1] + seed3 := leaderboard[2] + seed4 := leaderboard[3] + seed5 := leaderboard[4] + seed6 := leaderboard[5] + + // S1: QF1 - 3rd vs 6th + s1, err := NewPlayoffSeries(ctx, tx, bracket, 1, + "quarter_final", "QF1", + &seed3.Team.ID, &seed6.Team.ID, + intPtr(3), intPtr(6), + getMatchesToWin(roundFormats, "quarter_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create S1") + } + + // S2: QF2 - 4th vs 5th + s2, err := NewPlayoffSeries(ctx, tx, bracket, 2, + "quarter_final", "QF2", + &seed4.Team.ID, &seed5.Team.ID, + intPtr(4), intPtr(5), + getMatchesToWin(roundFormats, "quarter_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create S2") + } + + // S3: SF1 - 1st vs Winner(QF1) + s3, err := NewPlayoffSeries(ctx, tx, bracket, 3, + "semi_final", "SF1", + &seed1.Team.ID, nil, + intPtr(1), nil, + getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create S3") + } + + // S4: SF2 - 2nd vs Winner(QF2) + s4, err := NewPlayoffSeries(ctx, tx, bracket, 4, + "semi_final", "SF2", + &seed2.Team.ID, nil, + intPtr(2), nil, + getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create S4") + } + + // S5: Third Place Playoff - Loser(SF1) vs Loser(SF2) + s5, err := NewPlayoffSeries(ctx, tx, bracket, 5, + "third_place", "Third Place Playoff", + nil, nil, + nil, nil, + getMatchesToWin(roundFormats, "third_place"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create S5") + } + + // S6: Grand Final - Winner(SF1) vs Winner(SF2) + s6, err := NewPlayoffSeries(ctx, tx, bracket, 6, + "grand_final", "Grand Final", + nil, nil, + nil, nil, + getMatchesToWin(roundFormats, "grand_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create S6") + } + + // Wire up advancement + // S1 QF1: Winner -> S3 SF1 (team2) + err = SetSeriesAdvancement(ctx, tx, s1.ID, + &s3.ID, strPtr("team2"), nil, nil) + if err != nil { + return errors.Wrap(err, "wire S1") + } + + // S2 QF2: Winner -> S4 SF2 (team2) + err = SetSeriesAdvancement(ctx, tx, s2.ID, + &s4.ID, strPtr("team2"), nil, nil) + if err != nil { + return errors.Wrap(err, "wire S2") + } + + // S3 SF1: Winner -> S6 GF (team1), Loser -> S5 3rd Place (team1) + err = SetSeriesAdvancement(ctx, tx, s3.ID, + &s6.ID, strPtr("team1"), &s5.ID, strPtr("team1")) + if err != nil { + return errors.Wrap(err, "wire S3") + } + + // S4 SF2: Winner -> S6 GF (team2), Loser -> S5 3rd Place (team2) + err = SetSeriesAdvancement(ctx, tx, s4.ID, + &s6.ID, strPtr("team2"), &s5.ID, strPtr("team2")) + if err != nil { + return errors.Wrap(err, "wire S4") + } + + // S5 and S6 are terminal โ€” no advancement + _ = s5 + _ = s6 + return nil +} + +// generate10to15Bracket creates a finals bracket for 10-15 teams: +// +// Qualifying Finals (QF1-QF4): Top 4 get second chance +// QF1: 1st vs 4th +// QF2: 2nd vs 3rd +// QF3: 5th vs 8th +// QF4: 6th vs 7th +// +// Semi Finals: +// SF1: Loser(QF1) vs Winner(QF4) โ€” loser eliminated +// SF2: Loser(QF2) vs Winner(QF3) โ€” loser eliminated +// +// Preliminary Finals: +// PF1: Winner(QF1) vs Winner(SF2) +// PF2: Winner(QF2) vs Winner(SF1) +// +// Third Place Playoff: +// 3rd: Loser(PF1) vs Loser(PF2) +// +// Grand Final: +// GF: Winner(PF1) vs Winner(PF2) +func generate10to15Bracket( + ctx context.Context, + tx bun.Tx, + bracket *PlayoffBracket, + leaderboard []*LeaderboardEntry, + roundFormats map[string]int, +) error { + seed1 := leaderboard[0] + seed2 := leaderboard[1] + seed3 := leaderboard[2] + seed4 := leaderboard[3] + seed5 := leaderboard[4] + seed6 := leaderboard[5] + seed7 := leaderboard[6] + seed8 := leaderboard[7] + + // Qualifying Finals + qf1, err := NewPlayoffSeries(ctx, tx, bracket, 1, + "qualifying_final", "QF1", + &seed1.Team.ID, &seed4.Team.ID, + intPtr(1), intPtr(4), + getMatchesToWin(roundFormats, "qualifying_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create QF1") + } + + qf2, err := NewPlayoffSeries(ctx, tx, bracket, 2, + "qualifying_final", "QF2", + &seed2.Team.ID, &seed3.Team.ID, + intPtr(2), intPtr(3), + getMatchesToWin(roundFormats, "qualifying_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create QF2") + } + + // Elimination Finals + qf3, err := NewPlayoffSeries(ctx, tx, bracket, 3, + "elimination_final", "EF1", + &seed5.Team.ID, &seed8.Team.ID, + intPtr(5), intPtr(8), + getMatchesToWin(roundFormats, "elimination_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create EF1") + } + + qf4, err := NewPlayoffSeries(ctx, tx, bracket, 4, + "elimination_final", "EF2", + &seed6.Team.ID, &seed7.Team.ID, + intPtr(6), intPtr(7), + getMatchesToWin(roundFormats, "elimination_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create EF2") + } + + // Semi Finals + sf1, err := NewPlayoffSeries(ctx, tx, bracket, 5, + "semi_final", "SF1", + nil, nil, // Loser(QF1) vs Winner(EF2) + nil, nil, + getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create SF1") + } + + sf2, err := NewPlayoffSeries(ctx, tx, bracket, 6, + "semi_final", "SF2", + nil, nil, // Loser(QF2) vs Winner(EF1) + nil, nil, + getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create SF2") + } + + // Preliminary Finals + pf1, err := NewPlayoffSeries(ctx, tx, bracket, 7, + "preliminary_final", "PF1", + nil, nil, // Winner(QF1) vs Winner(SF2) + nil, nil, + getMatchesToWin(roundFormats, "preliminary_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create PF1") + } + + pf2, err := NewPlayoffSeries(ctx, tx, bracket, 8, + "preliminary_final", "PF2", + nil, nil, // Winner(QF2) vs Winner(SF1) + nil, nil, + getMatchesToWin(roundFormats, "preliminary_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create PF2") + } + + // Third Place Playoff - Loser(PF1) vs Loser(PF2) + tp, err := NewPlayoffSeries(ctx, tx, bracket, 9, + "third_place", "Third Place Playoff", + nil, nil, + nil, nil, + getMatchesToWin(roundFormats, "third_place"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create 3rd Place") + } + + // Grand Final + gf, err := NewPlayoffSeries(ctx, tx, bracket, 10, + "grand_final", "Grand Final", + nil, nil, + nil, nil, + getMatchesToWin(roundFormats, "grand_final"), SeriesStatusPending) + if err != nil { + return errors.Wrap(err, "create GF") + } + + // Wire up advancement + // QF1: Winner -> PF1 (team1), Loser -> SF1 (team1) + err = SetSeriesAdvancement(ctx, tx, qf1.ID, + &pf1.ID, strPtr("team1"), &sf1.ID, strPtr("team1")) + if err != nil { + return errors.Wrap(err, "wire QF1") + } + + // QF2: Winner -> PF2 (team1), Loser -> SF2 (team1) + err = SetSeriesAdvancement(ctx, tx, qf2.ID, + &pf2.ID, strPtr("team1"), &sf2.ID, strPtr("team1")) + if err != nil { + return errors.Wrap(err, "wire QF2") + } + + // EF1 (QF3): Winner -> SF2 (team2), Loser eliminated + err = SetSeriesAdvancement(ctx, tx, qf3.ID, + &sf2.ID, strPtr("team2"), nil, nil) + if err != nil { + return errors.Wrap(err, "wire EF1") + } + + // EF2 (QF4): Winner -> SF1 (team2), Loser eliminated + err = SetSeriesAdvancement(ctx, tx, qf4.ID, + &sf1.ID, strPtr("team2"), nil, nil) + if err != nil { + return errors.Wrap(err, "wire EF2") + } + + // SF1: Winner -> PF2 (team2), Loser eliminated + err = SetSeriesAdvancement(ctx, tx, sf1.ID, + &pf2.ID, strPtr("team2"), nil, nil) + if err != nil { + return errors.Wrap(err, "wire SF1") + } + + // SF2: Winner -> PF1 (team2), Loser eliminated + err = SetSeriesAdvancement(ctx, tx, sf2.ID, + &pf1.ID, strPtr("team2"), nil, nil) + if err != nil { + return errors.Wrap(err, "wire SF2") + } + + // PF1: Winner -> GF (team1), Loser -> 3rd Place (team1) + err = SetSeriesAdvancement(ctx, tx, pf1.ID, + &gf.ID, strPtr("team1"), &tp.ID, strPtr("team1")) + if err != nil { + return errors.Wrap(err, "wire PF1") + } + + // PF2: Winner -> GF (team2), Loser -> 3rd Place (team2) + err = SetSeriesAdvancement(ctx, tx, pf2.ID, + &gf.ID, strPtr("team2"), &tp.ID, strPtr("team2")) + if err != nil { + return errors.Wrap(err, "wire PF2") + } + + // 3rd Place and Grand Final are terminal โ€” no advancement + _ = tp + _ = gf + return nil +} diff --git a/internal/db/setup.go b/internal/db/setup.go index 7d7cb68..a335dbc 100644 --- a/internal/db/setup.go +++ b/internal/db/setup.go @@ -38,6 +38,9 @@ func (db *DB) RegisterModels() []any { (*Player)(nil), (*FixtureResult)(nil), (*FixtureResultPlayerStats)(nil), + (*PlayoffBracket)(nil), + (*PlayoffSeries)(nil), + (*PlayoffMatch)(nil), } db.RegisterModel(models...) return models diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 3184498..57c827f 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -267,9 +267,6 @@ .top-0 { top: calc(var(--spacing) * 0); } - .top-1 { - top: calc(var(--spacing) * 1); - } .top-1\/2 { top: calc(1 / 2 * 100%); } @@ -339,9 +336,6 @@ .-mt-3 { margin-top: calc(var(--spacing) * -3); } - .mt-0 { - margin-top: calc(var(--spacing) * 0); - } .mt-0\.5 { margin-top: calc(var(--spacing) * 0.5); } @@ -375,9 +369,6 @@ .mt-12 { margin-top: calc(var(--spacing) * 12); } - .mt-16 { - margin-top: calc(var(--spacing) * 16); - } .mt-24 { margin-top: calc(var(--spacing) * 24); } @@ -493,6 +484,9 @@ .h-screen { height: 100vh; } + .max-h-40 { + max-height: calc(var(--spacing) * 40); + } .max-h-60 { max-height: calc(var(--spacing) * 60); } @@ -559,6 +553,9 @@ .w-20 { width: calc(var(--spacing) * 20); } + .w-24 { + width: calc(var(--spacing) * 24); + } .w-26 { width: calc(var(--spacing) * 26); } @@ -634,22 +631,12 @@ .flex-1 { flex: 1; } - .flex-shrink { - flex-shrink: 1; - } .flex-shrink-0 { flex-shrink: 0; } .shrink-0 { flex-shrink: 0; } - .border-collapse { - border-collapse: collapse; - } - .-translate-y-1 { - --tw-translate-y: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -758,9 +745,6 @@ .justify-end { justify-content: flex-end; } - .gap-0 { - gap: calc(var(--spacing) * 0); - } .gap-0\.5 { gap: calc(var(--spacing) * 0.5); } @@ -992,9 +976,6 @@ .border-overlay0 { border-color: var(--overlay0); } - .border-peach { - border-color: var(--peach); - } .border-peach\/50 { border-color: var(--peach); @supports (color: color-mix(in lab, red, red)) { @@ -1088,6 +1069,12 @@ .bg-green { background-color: var(--green); } + .bg-green\/5 { + background-color: var(--green); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--green) 5%, transparent); + } + } .bg-green\/10 { background-color: var(--green); @supports (color: color-mix(in lab, red, red)) { @@ -1112,9 +1099,6 @@ .bg-mauve { background-color: var(--mauve); } - .bg-overlay0 { - background-color: var(--overlay0); - } .bg-overlay0\/10 { background-color: var(--overlay0); @supports (color: color-mix(in lab, red, red)) { @@ -1262,9 +1246,6 @@ .px-6 { padding-inline: calc(var(--spacing) * 6); } - .py-0 { - padding-block: calc(var(--spacing) * 0); - } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } @@ -1557,10 +1538,6 @@ --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } - .outline { - outline-style: var(--tw-outline-style); - outline-width: 1px; - } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); @@ -1865,6 +1842,16 @@ } } } + .hover\:bg-surface0\/50 { + &:hover { + @media (hover: hover) { + background-color: var(--surface0); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--surface0) 50%, transparent); + } + } + } + } .hover\:bg-surface1 { &:hover { @media (hover: hover) { @@ -2685,11 +2672,6 @@ inherits: false; initial-value: 0 0 #0000; } -@property --tw-outline-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} @property --tw-blur { syntax: "*"; inherits: false; @@ -2792,7 +2774,6 @@ --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; - --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; diff --git a/internal/handlers/season_league_finals.go b/internal/handlers/season_league_finals.go index 58f665c..5288d1e 100644 --- a/internal/handlers/season_league_finals.go +++ b/internal/handlers/season_league_finals.go @@ -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_ 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 +} diff --git a/internal/permissions/constants.go b/internal/permissions/constants.go index cbbc646..eb666d5 100644 --- a/internal/permissions/constants.go +++ b/internal/permissions/constants.go @@ -40,6 +40,9 @@ const ( FixturesCreate Permission = "fixtures.create" FixturesDelete Permission = "fixtures.delete" + // Playoffs permissions + PlayoffsManage Permission = "playoffs.manage" + // Free Agent permissions FreeAgentsAdd Permission = "free_agents.add" FreeAgentsRemove Permission = "free_agents.remove" diff --git a/internal/server/routes.go b/internal/server/routes.go index b7afe09..3b39c2d 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -137,6 +137,16 @@ func addRoutes( Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, Handler: handlers.SeasonLeagueFinalsPage(s, conn), }, + { + Path: "/seasons/{season_short_name}/leagues/{league_short_name}/finals/setup", + Method: hws.MethodGET, + Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeasonLeagueFinalsSetupForm(s, conn)), + }, + { + Path: "/seasons/{season_short_name}/leagues/{league_short_name}/finals/setup", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeasonLeagueFinalsSetupSubmit(s, conn)), + }, { Path: "/seasons/{season_short_name}/add-league/{league_short_name}", Method: hws.MethodPOST, diff --git a/internal/view/seasonsview/finals_setup_form.templ b/internal/view/seasonsview/finals_setup_form.templ new file mode 100644 index 0000000..a830970 --- /dev/null +++ b/internal/view/seasonsview/finals_setup_form.templ @@ -0,0 +1,268 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/datepicker" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" + +templ FinalsSetupForm( + season *db.Season, + league *db.League, + leaderboard []*db.LeaderboardEntry, + unplayedFixtures []*db.Fixture, +) { + {{ + // Determine the recommended format value for the default Alpine state + defaultFormat := "" + if len(leaderboard) >= 10 && len(leaderboard) <= 15 { + defaultFormat = string(db.PlayoffFormat10to15) + } else if len(leaderboard) >= 7 && len(leaderboard) <= 9 { + defaultFormat = string(db.PlayoffFormat7to9) + } else if len(leaderboard) >= 5 && len(leaderboard) <= 6 { + defaultFormat = string(db.PlayoffFormat5to6) + } + }} +
    +
    + +
    +

    + + Begin Finals Setup +

    +

    + Configure playoff format and dates for { league.Name } +

    +
    + + +
    +
    + @datepicker.DatePicker( + "regular_season_end_date", + "regular_season_end_date", + "Regular Season End Date", + "DD/MM/YYYY", + true, + "", + ) +

    Games after this date will be forfeited

    +
    +
    + @datepicker.DatePicker( + "finals_start_date", + "finals_start_date", + "Finals Start Date", + "DD/MM/YYYY", + true, + "", + ) +

    First playoff matches begin on this date

    +
    +
    + +
    + +
    + @formatOption( + string(db.PlayoffFormat5to6), + "5-6 Teams", + "Top 5 qualify. 1st earns a bye, 2nd vs 3rd (upper), 4th vs 5th (lower). Double-chance for top seeds.", + len(leaderboard) >= 5 && len(leaderboard) <= 6, + len(leaderboard), + ) + @formatOption( + string(db.PlayoffFormat7to9), + "7-9 Teams", + "Top 6 qualify. 1st & 2nd placed into semis. 3rd vs 6th and 4th vs 5th in quarter finals.", + len(leaderboard) >= 7 && len(leaderboard) <= 9, + len(leaderboard), + ) + @formatOption( + string(db.PlayoffFormat10to15), + "10-15 Teams", + "Top 8 qualify. Top 4 get a second chance in qualifying finals.", + len(leaderboard) >= 10 && len(leaderboard) <= 15, + len(leaderboard), + ) +
    +
    + +
    + +
    + + + + + + +
    +
    + + if len(unplayedFixtures) > 0 { +
    +
    + + + +
    +

    + { fmt.Sprintf("%d unplayed fixture(s) found", len(unplayedFixtures)) } +

    +

    + These fixtures will be recorded as a mutual forfeit when you begin finals. + This action cannot be undone. +

    +
    + for _, fixture := range unplayedFixtures { +
    + GW{ fmt.Sprint(*fixture.GameWeek) } + { fixture.HomeTeam.Name } + vs + { fixture.AwayTeam.Name } +
    + } +
    +
    +
    +
    + } + + if len(leaderboard) > 0 { +
    + +
    + + + + + + + + + + + for _, entry := range leaderboard { + @standingsPreviewRow(entry, season, league) + } + +
    #TeamGPPTS
    +
    +
    + } + +
    + + +
    + +
    +
    +} + +templ formatOption(value, label, description string, recommended bool, teamCount int) { + +} + +templ boRoundDropdown(name, label, description string) { +
    +
    + { label } +

    { description }

    +
    + +
    +} + +templ standingsPreviewRow(entry *db.LeaderboardEntry, season *db.Season, league *db.League) { + + + { fmt.Sprint(entry.Position) } + + + @links.TeamLinkInSeason(entry.Team, season, league) + + + { fmt.Sprint(entry.Record.Played) } + + + { fmt.Sprint(entry.Record.Points) } + + +} diff --git a/internal/view/seasonsview/playoff_bracket.templ b/internal/view/seasonsview/playoff_bracket.templ new file mode 100644 index 0000000..5125487 --- /dev/null +++ b/internal/view/seasonsview/playoff_bracket.templ @@ -0,0 +1,165 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" + +// PlayoffBracketView renders the full bracket visualization +templ PlayoffBracketView(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { +
    + +
    +
    +

    + + Finals Bracket +

    +

    + { formatLabel(bracket.Format) } +

    +
    +
    + @playoffStatusBadge(bracket.Status) +
    +
    + +
    + @bracketRounds(season, league, bracket) +
    +
    +} + +// bracketRounds groups series by round and renders them +templ bracketRounds(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { + {{ + // Group series by round + rounds := groupSeriesByRound(bracket.Series) + roundOrder := getRoundOrder(bracket.Format) + }} + for _, roundName := range roundOrder { + if series, ok := rounds[roundName]; ok { +
    +

    + { formatRoundName(roundName) } +

    +
    + for _, s := range series { + @seriesCard(season, league, s) + } +
    +
    + } + } +} + +templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) { +
    + +
    +
    + { series.Label } + @seriesFormatBadge(series.MatchesToWin) +
    + @seriesStatusBadge(series.Status) +
    + +
    + @seriesTeamRow(season, league, series.Team1, series.Team1Seed, series.Team1Wins, + series.WinnerTeamID, series.MatchesToWin) + @seriesTeamRow(season, league, series.Team2, series.Team2Seed, series.Team2Wins, + series.WinnerTeamID, series.MatchesToWin) +
    + + if series.MatchesToWin > 1 { +
    + { fmt.Sprint(series.Team1Wins) } - { fmt.Sprint(series.Team2Wins) } +
    + } +
    +} + +templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *int, wins int, winnerID *int, matchesToWin int) { + {{ + isWinner := false + if team != nil && winnerID != nil { + isWinner = team.ID == *winnerID + } + isTBD := team == nil + }} +
    +
    + if seed != nil { + + { fmt.Sprint(*seed) } + + } else { + - + } + if isTBD { + TBD + } else { + @links.TeamLinkInSeason(team, season, league) + if isWinner { + โœ“ + } + } +
    + if matchesToWin > 1 { + + { fmt.Sprint(wins) } + + } +
    +} + +templ playoffStatusBadge(status db.PlayoffStatus) { + switch status { + case db.PlayoffStatusUpcoming: + + Upcoming + + case db.PlayoffStatusInProgress: + + In Progress + + case db.PlayoffStatusCompleted: + + Completed + + } +} + +templ seriesFormatBadge(matchesToWin int) { + {{ + label := fmt.Sprintf("BO%d", matchesToWin*2-1) + }} + + { label } + +} + +templ seriesStatusBadge(status db.SeriesStatus) { + switch status { + case db.SeriesStatusPending: + + Pending + + case db.SeriesStatusInProgress: + + Live + + case db.SeriesStatusCompleted: + + Complete + + case db.SeriesStatusBye: + + Bye + + } +} diff --git a/internal/view/seasonsview/playoff_helpers.go b/internal/view/seasonsview/playoff_helpers.go new file mode 100644 index 0000000..3dc7db9 --- /dev/null +++ b/internal/view/seasonsview/playoff_helpers.go @@ -0,0 +1,88 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" + +// groupSeriesByRound groups playoff series by their round field +func groupSeriesByRound(series []*db.PlayoffSeries) map[string][]*db.PlayoffSeries { + grouped := make(map[string][]*db.PlayoffSeries) + for _, s := range series { + grouped[s.Round] = append(grouped[s.Round], s) + } + return grouped +} + +// getRoundOrder returns the display order of rounds for a given format +func getRoundOrder(format db.PlayoffFormat) []string { + switch format { + case db.PlayoffFormat5to6: + return []string{ + "upper_bracket", + "lower_bracket", + "upper_final", + "lower_final", + "grand_final", + } + case db.PlayoffFormat7to9: + return []string{ + "quarter_final", + "semi_final", + "third_place", + "grand_final", + } + case db.PlayoffFormat10to15: + return []string{ + "qualifying_final", + "elimination_final", + "semi_final", + "preliminary_final", + "third_place", + "grand_final", + } + default: + return nil + } +} + +// formatRoundName converts a round slug to a human-readable name +func formatRoundName(round string) string { + switch round { + case "upper_bracket": + return "Upper Bracket" + case "lower_bracket": + return "Lower Bracket" + case "upper_final": + return "Upper Final" + case "lower_final": + return "Lower Final" + case "quarter_final": + return "Quarter Finals" + case "semi_final": + return "Semi Finals" + case "qualifying_final": + return "Qualifying Finals" + case "elimination_final": + return "Elimination Finals" + case "preliminary_final": + return "Preliminary Finals" + case "third_place": + return "Third Place Playoff" + case "grand_final": + return "Grand Final" + default: + return round + } +} + +// formatLabel returns a human-readable format description +func formatLabel(format db.PlayoffFormat) string { + switch format { + case db.PlayoffFormat5to6: + return "Top 5 qualify" + case db.PlayoffFormat7to9: + return "Top 6 qualify" + case db.PlayoffFormat10to15: + return "Top 8 qualify" + default: + return string(format) + } +} diff --git a/internal/view/seasonsview/season_league_finals.templ b/internal/view/seasonsview/season_league_finals.templ index 2311ef4..102f6a4 100644 --- a/internal/view/seasonsview/season_league_finals.templ +++ b/internal/view/seasonsview/season_league_finals.templ @@ -1,15 +1,59 @@ package seasonsview import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/contexts" +import "git.haelnorr.com/h/oslstats/internal/permissions" +import "fmt" -templ SeasonLeagueFinalsPage(season *db.Season, league *db.League) { +templ SeasonLeagueFinalsPage(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { @SeasonLeagueLayout("finals", season, league) { - @SeasonLeagueFinals() + @SeasonLeagueFinals(season, league, bracket) } } -templ SeasonLeagueFinals() { -
    -

    Coming Soon...

    +templ SeasonLeagueFinals(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { + {{ + status := season.GetStatus() + permCache := contexts.Permissions(ctx) + canManagePlayoffs := permCache != nil && permCache.HasPermission(permissions.PlayoffsManage) + }} +
    + if bracket != nil { + @PlayoffBracketView(season, league, bracket) + } else if status == db.StatusInProgress || status == db.StatusUpcoming { + @finalsRegularSeasonInProgress(season, league, canManagePlayoffs) + } else { + @finalsNotConfigured() + } +
    +} + +templ finalsRegularSeasonInProgress(season *db.Season, league *db.League, canManagePlayoffs bool) { +
    +
    + + + +
    +

    Regular Season in Progress

    +

    + Finals will be available once the regular season is complete. +

    + if canManagePlayoffs { + + } +
    +} + +templ finalsNotConfigured() { +
    +

    No finals configured for this league.

    } diff --git a/justfile b/justfile index a6d9943..e1505d8 100644 --- a/justfile +++ b/justfile @@ -122,3 +122,88 @@ _migrate-new name: && _build _migrate-status reset-db env='.env': _build echo "โš ๏ธ WARNING - This will DELETE ALL DATA!" {{bin}}/{{entrypoint}} --reset-db --envfile {{env}} + +# Restore database from a production backup (.sql) +[group('db')] +[confirm("โš ๏ธ This will DELETE ALL DATA in the dev database and replace it with the backup. Continue?")] +[script] +restore-db backup_file env='.env': + set -euo pipefail + + # Source env vars + set -a + source ./{{env}} + set +a + + DB_USER="${DB_USER}" + DB_PASSWORD="${DB_PASSWORD}" + DB_HOST="${DB_HOST}" + DB_PORT="${DB_PORT:-5432}" + DB_NAME="${DB_NAME}" + PROD_USER="oslstats" + + export PGPASSWORD="$DB_PASSWORD" + + echo "[INFO] Restoring database from: {{backup_file}}" + echo "[INFO] Target: $DB_NAME on $DB_HOST:$DB_PORT as $DB_USER" + echo "" + + # Step 1: Drop and recreate the database + echo "[INFO] Step 1/4: Dropping and recreating database..." + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c \ + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();" \ + > /dev/null 2>&1 || true + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS \"$DB_NAME\";" + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "CREATE DATABASE \"$DB_NAME\" OWNER \"$DB_USER\";" + echo "[INFO] Database recreated" + + # Step 2: Preprocess and restore the dump (remap ownership) + echo "[INFO] Step 2/4: Restoring backup (remapping owner $PROD_USER โ†’ $DB_USER)..." + sed \ + -e "s/OWNER TO ${PROD_USER}/OWNER TO ${DB_USER}/g" \ + -e "s/Owner: ${PROD_USER}/Owner: ${DB_USER}/g" \ + -e "/^ALTER DEFAULT PRIVILEGES/d" \ + -e "s/GRANT ALL ON \(.*\) TO ${PROD_USER}/GRANT ALL ON \1 TO ${DB_USER}/g" \ + "{{backup_file}}" | psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" --quiet --single-transaction + echo "[INFO] Backup restored" + + # Step 3: Reassign all ownership as safety net + echo "[INFO] Step 3/4: Reassigning remaining ownership to $DB_USER..." + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" < Date: Mon, 9 Mar 2026 13:01:28 +1100 Subject: [PATCH 27/33] playoff visual fixes --- .gitignore | 2 + internal/db/playoff_generation.go | 32 +-- internal/db/season.go | 27 ++- internal/embedfs/web/css/output.css | 13 ++ internal/embedfs/web/js/bracket-lines.js | 152 +++++++++++++ .../view/seasonsview/finals_setup_form.templ | 56 +++-- internal/view/seasonsview/list_page.templ | 37 ++-- .../view/seasonsview/playoff_bracket.templ | 205 +++++++++++++++--- internal/view/seasonsview/playoff_helpers.go | 134 ++++++------ .../seasonsview/season_league_finals.templ | 29 ++- internal/view/seasonsview/status_badge.templ | 56 ++--- 11 files changed, 528 insertions(+), 215 deletions(-) create mode 100644 internal/embedfs/web/js/bracket-lines.js diff --git a/.gitignore b/.gitignore index 7a77d4d..5220f0f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +*.env *.db* .logs/ server.log @@ -10,6 +11,7 @@ internal/view/**/*_templ.go internal/view/**/*_templ.txt cmd/test/* .opencode +prod-export.sql # Database backups (compressed) backups/*.sql.gz diff --git a/internal/db/playoff_generation.go b/internal/db/playoff_generation.go index 176cb55..e7a16b5 100644 --- a/internal/db/playoff_generation.go +++ b/internal/db/playoff_generation.go @@ -336,17 +336,19 @@ func generate7to9Bracket( // generate10to15Bracket creates a finals bracket for 10-15 teams: // -// Qualifying Finals (QF1-QF4): Top 4 get second chance +// Qualifying Finals: Top 4 get second chance // QF1: 1st vs 4th // QF2: 2nd vs 3rd -// QF3: 5th vs 8th -// QF4: 6th vs 7th // -// Semi Finals: -// SF1: Loser(QF1) vs Winner(QF4) โ€” loser eliminated -// SF2: Loser(QF2) vs Winner(QF3) โ€” loser eliminated +// Elimination Finals: Single elimination +// EF1: 5th vs 8th +// EF2: 6th vs 7th // -// Preliminary Finals: +// Semi Finals (same-side: QF loser faces same-side EF winner): +// SF1: Loser(QF1) vs Winner(EF1) โ€” loser eliminated +// SF2: Loser(QF2) vs Winner(EF2) โ€” loser eliminated +// +// Preliminary Finals (QF winner vs opposite SF winner): // PF1: Winner(QF1) vs Winner(SF2) // PF2: Winner(QF2) vs Winner(SF1) // @@ -412,7 +414,7 @@ func generate10to15Bracket( // Semi Finals sf1, err := NewPlayoffSeries(ctx, tx, bracket, 5, "semi_final", "SF1", - nil, nil, // Loser(QF1) vs Winner(EF2) + nil, nil, // Loser(QF1) vs Winner(EF1) nil, nil, getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) if err != nil { @@ -421,7 +423,7 @@ func generate10to15Bracket( sf2, err := NewPlayoffSeries(ctx, tx, bracket, 6, "semi_final", "SF2", - nil, nil, // Loser(QF2) vs Winner(EF1) + nil, nil, // Loser(QF2) vs Winner(EF2) nil, nil, getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) if err != nil { @@ -482,28 +484,28 @@ func generate10to15Bracket( return errors.Wrap(err, "wire QF2") } - // EF1 (QF3): Winner -> SF2 (team2), Loser eliminated + // EF1 (QF3): Winner -> SF1 (team2), Loser eliminated err = SetSeriesAdvancement(ctx, tx, qf3.ID, - &sf2.ID, strPtr("team2"), nil, nil) + &sf1.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire EF1") } - // EF2 (QF4): Winner -> SF1 (team2), Loser eliminated + // EF2 (QF4): Winner -> SF2 (team2), Loser eliminated err = SetSeriesAdvancement(ctx, tx, qf4.ID, - &sf1.ID, strPtr("team2"), nil, nil) + &sf2.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire EF2") } - // SF1: Winner -> PF2 (team2), Loser eliminated + // SF1: Winner -> PF2 (team2), Loser eliminated (crosses to face QF2 winner) err = SetSeriesAdvancement(ctx, tx, sf1.ID, &pf2.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire SF1") } - // SF2: Winner -> PF1 (team2), Loser eliminated + // SF2: Winner -> PF1 (team2), Loser eliminated (crosses to face QF1 winner) err = SetSeriesAdvancement(ctx, tx, sf2.ID, &pf1.ID, strPtr("team2"), nil, nil) if err != nil { diff --git a/internal/db/season.go b/internal/db/season.go index 612e170..e1039a1 100644 --- a/internal/db/season.go +++ b/internal/db/season.go @@ -142,7 +142,12 @@ type LeagueWithTeams struct { Teams []*Team } -// GetStatus returns the current status of the season based on dates +// GetStatus returns the current status of the season based on dates. +// Dates are treated as inclusive days: +// - StartDate: season is "in progress" from the start of this day +// - EndDate: season is "in progress" through the end of this day +// - FinalsStartDate: finals are active from the start of this day +// - FinalsEndDate: finals are active through the end of this day func (s *Season) GetStatus() SeasonStatus { now := time.Now() @@ -150,20 +155,32 @@ func (s *Season) GetStatus() SeasonStatus { return StatusUpcoming } + // dayPassed returns true if the entire calendar day of t has passed. + // e.g., if t is March 8, this returns true starting March 9 00:00:00. + dayPassed := func(t time.Time) bool { + return now.After(t.Truncate(time.Hour*24).AddDate(0, 0, 1)) + } + + // dayStarted returns true if the calendar day of t has started. + // e.g., if t is March 8, this returns true starting March 8 00:00:00. + dayStarted := func(t time.Time) bool { + return !now.Before(t.Truncate(time.Hour * 24)) + } + if !s.FinalsStartDate.IsZero() { - if !s.FinalsEndDate.IsZero() && now.After(s.FinalsEndDate.Time) { + if !s.FinalsEndDate.IsZero() && dayPassed(s.FinalsEndDate.Time) { return StatusCompleted } - if now.After(s.FinalsStartDate.Time) { + if dayStarted(s.FinalsStartDate.Time) { return StatusFinals } - if !s.EndDate.IsZero() && now.After(s.EndDate.Time) { + if !s.EndDate.IsZero() && dayPassed(s.EndDate.Time) { return StatusFinalsSoon } return StatusInProgress } - if !s.EndDate.IsZero() && now.After(s.EndDate.Time) { + if !s.EndDate.IsZero() && dayPassed(s.EndDate.Time) { return StatusCompleted } diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 57c827f..6a84a71 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -445,6 +445,9 @@ width: calc(var(--spacing) * 5); height: calc(var(--spacing) * 5); } + .h-0 { + height: calc(var(--spacing) * 0); + } .h-1 { height: calc(var(--spacing) * 1); } @@ -628,6 +631,12 @@ .min-w-0 { min-width: calc(var(--spacing) * 0); } + .min-w-\[500px\] { + min-width: 500px; + } + .min-w-\[700px\] { + min-width: 700px; + } .flex-1 { flex: 1; } @@ -943,6 +952,10 @@ border-top-style: var(--tw-border-style); border-top-width: 1px; } + .border-t-2 { + border-top-style: var(--tw-border-style); + border-top-width: 2px; + } .border-b { border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; diff --git a/internal/embedfs/web/js/bracket-lines.js b/internal/embedfs/web/js/bracket-lines.js new file mode 100644 index 0000000..e6e69be --- /dev/null +++ b/internal/embedfs/web/js/bracket-lines.js @@ -0,0 +1,152 @@ +// bracket-lines.js +// Draws smooth SVG bezier connector lines between series cards in a playoff bracket. +// Lines connect from the bottom-center of a source card to the top-center of a +// destination card. Winner paths are solid green, loser paths are dashed red. +// +// Usage: Add data-bracket-lines to a container element. Inside, series cards +// should have data-series="N" attributes. The container needs a data-connections +// attribute with a JSON array of connection objects: +// [{"from": 1, "to": 3, "type": "winner"}, ...] +// +// Optional toSide field ("left" or "right") makes the line arrive at the +// left or right edge (vertically centered) of the destination card instead +// of the top-center. +// +// An SVG element with data-bracket-svg inside the container is used for drawing. + +(function () { + var WINNER_COLOR = "var(--green)"; + var LOSER_COLOR = "var(--red)"; + var STROKE_WIDTH = 2; + var DASH_ARRAY = "6 3"; + + // Curvature control: how far the control points extend + // as a fraction of the total distance between cards + var CURVE_FACTOR = 0.4; + + function drawBracketLines(container) { + var svg = container.querySelector("[data-bracket-svg]"); + if (!svg) return; + + var connectionsAttr = container.getAttribute("data-connections"); + if (!connectionsAttr) return; + + var connections; + try { + connections = JSON.parse(connectionsAttr); + } catch (e) { + return; + } + + // Clear existing paths + while (svg.firstChild) { + svg.removeChild(svg.firstChild); + } + + // Get container position for relative coordinates + var containerRect = container.getBoundingClientRect(); + + // Size SVG to match container + svg.setAttribute("width", containerRect.width); + svg.setAttribute("height", containerRect.height); + + connections.forEach(function (conn) { + var fromCard = container.querySelector( + '[data-series="' + conn.from + '"]', + ); + var toCard = container.querySelector('[data-series="' + conn.to + '"]'); + if (!fromCard || !toCard) return; + + var fromRect = fromCard.getBoundingClientRect(); + var toRect = toCard.getBoundingClientRect(); + + // Start: bottom-center of source card + var x1 = fromRect.left + fromRect.width / 2 - containerRect.left; + var y1 = fromRect.bottom - containerRect.top; + + var x2, y2, d; + + if (conn.toSide === "left") { + // End: left edge, vertically centered + x2 = toRect.left - containerRect.left; + y2 = toRect.top + toRect.height / 2 - containerRect.top; + + // Bezier: go down first, then curve into the left side + var dy = y2 - y1; + var dx = x2 - x1; + d = + "M " + x1 + " " + y1 + + " C " + x1 + " " + (y1 + dy * 0.5) + + ", " + (x2 + dx * 0.2) + " " + y2 + + ", " + x2 + " " + y2; + } else if (conn.toSide === "right") { + // End: right edge, vertically centered + x2 = toRect.right - containerRect.left; + y2 = toRect.top + toRect.height / 2 - containerRect.top; + + // Bezier: go down first, then curve into the right side + var dy = y2 - y1; + var dx = x2 - x1; + d = + "M " + x1 + " " + y1 + + " C " + x1 + " " + (y1 + dy * 0.5) + + ", " + (x2 + dx * 0.2) + " " + y2 + + ", " + x2 + " " + y2; + } else { + // Default: end at top-center of destination card + x2 = toRect.left + toRect.width / 2 - containerRect.left; + y2 = toRect.top - containerRect.top; + + var dy = y2 - y1; + d = + "M " + x1 + " " + y1 + + " C " + x1 + " " + (y1 + dy * CURVE_FACTOR) + + ", " + x2 + " " + (y2 - dy * CURVE_FACTOR) + + ", " + x2 + " " + y2; + } + + var path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", d); + path.setAttribute("fill", "none"); + path.setAttribute("stroke-width", STROKE_WIDTH); + + if (conn.type === "winner") { + path.setAttribute("stroke", WINNER_COLOR); + } else { + path.setAttribute("stroke", LOSER_COLOR); + path.setAttribute("stroke-dasharray", DASH_ARRAY); + } + + svg.appendChild(path); + }); + } + + function drawAllBrackets() { + var containers = document.querySelectorAll("[data-bracket-lines]"); + containers.forEach(drawBracketLines); + } + + // Draw on initial load + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", drawAllBrackets); + } else { + // DOM already loaded (e.g. script loaded via HTMX swap) + drawAllBrackets(); + } + + // Redraw on window resize (debounced) + var resizeTimer; + window.addEventListener("resize", function () { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(drawAllBrackets, 100); + }); + + // Redraw after HTMX swaps + document.addEventListener("htmx:afterSwap", function () { + // Small delay to let the DOM settle + setTimeout(drawAllBrackets, 50); + }); + + // Expose for manual triggering if needed + window.drawBracketLines = drawAllBrackets; +})(); diff --git a/internal/view/seasonsview/finals_setup_form.templ b/internal/view/seasonsview/finals_setup_form.templ index a830970..b78fdca 100644 --- a/internal/view/seasonsview/finals_setup_form.templ +++ b/internal/view/seasonsview/finals_setup_form.templ @@ -21,6 +21,16 @@ templ FinalsSetupForm( } else if len(leaderboard) >= 5 && len(leaderboard) <= 6 { defaultFormat = string(db.PlayoffFormat5to6) } + + // Prefill dates from existing season values + endDateDefault := "" + if !season.EndDate.IsZero() { + endDateDefault = season.EndDate.Time.Format("02/01/2006") + } + finalsStartDefault := "" + if !season.FinalsStartDate.IsZero() { + finalsStartDefault = season.FinalsStartDate.Time.Format("02/01/2006") + } }}
    -
    - @datepicker.DatePicker( - "regular_season_end_date", - "regular_season_end_date", - "Regular Season End Date", - "DD/MM/YYYY", - true, - "", - ) -

    Games after this date will be forfeited

    -
    -
    - @datepicker.DatePicker( - "finals_start_date", - "finals_start_date", - "Finals Start Date", - "DD/MM/YYYY", - true, - "", - ) -

    First playoff matches begin on this date

    -
    +
    + @datepicker.DatePickerWithDefault( + "regular_season_end_date", + "regular_season_end_date", + "Regular Season End Date", + "DD/MM/YYYY", + true, + "", + endDateDefault, + ) +

    Last day of the regular season (inclusive)

    +
    +
    + @datepicker.DatePickerWithDefault( + "finals_start_date", + "finals_start_date", + "Finals Start Date", + "DD/MM/YYYY", + true, + "", + finalsStartDefault, + ) +

    First playoff matches begin on this date

    +
    diff --git a/internal/view/seasonsview/list_page.templ b/internal/view/seasonsview/list_page.templ index bff68bb..8143b80 100644 --- a/internal/view/seasonsview/list_page.templ +++ b/internal/view/seasonsview/list_page.templ @@ -115,29 +115,28 @@ templ SeasonsList(seasons *db.List[db.Season]) { }
    } - - {{ - now := time.Now() - }} -
    - if now.Before(s.StartDate) { + + {{ + listStatus := s.GetStatus() + }} +
    + switch listStatus { + case db.StatusUpcoming: Starts: { formatDate(s.StartDate) } - } else if !s.FinalsStartDate.IsZero() { - // Finals are scheduled - if !s.FinalsEndDate.IsZero() && now.After(s.FinalsEndDate.Time) { + case db.StatusCompleted: + if !s.FinalsEndDate.IsZero() { Completed: { formatDate(s.FinalsEndDate.Time) } - } else if now.After(s.FinalsStartDate.Time) { - Finals Started: { formatDate(s.FinalsStartDate.Time) } - } else { - Finals Start: { formatDate(s.FinalsStartDate.Time) } + } else if !s.EndDate.IsZero() { + Completed: { formatDate(s.EndDate.Time) } } - } else if !s.EndDate.IsZero() && now.After(s.EndDate.Time) { - // No finals scheduled and regular season ended - Completed: { formatDate(s.EndDate.Time) } - } else { + case db.StatusFinals: + Finals Started: { formatDate(s.FinalsStartDate.Time) } + case db.StatusFinalsSoon: + Finals Start: { formatDate(s.FinalsStartDate.Time) } + default: Started: { formatDate(s.StartDate) } - } -
    + } +
    }
    diff --git a/internal/view/seasonsview/playoff_bracket.templ b/internal/view/seasonsview/playoff_bracket.templ index 5125487..fe33d3c 100644 --- a/internal/view/seasonsview/playoff_bracket.templ +++ b/internal/view/seasonsview/playoff_bracket.templ @@ -22,42 +22,181 @@ templ PlayoffBracketView(season *db.Season, league *db.League, bracket *db.Playo @playoffStatusBadge(bracket.Status)
    - -
    - @bracketRounds(season, league, bracket) + + switch bracket.Format { + case db.PlayoffFormat5to6: + @bracket5to6(season, league, bracket) + case db.PlayoffFormat7to9: + @bracket7to9(season, league, bracket) + case db.PlayoffFormat10to15: + @bracket10to15(season, league, bracket) + } + +
    +
    + + Winner +
    +
    + + Loser +
    +
    +
    + +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// 5-6 TEAMS FORMAT +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Round 1: [Upper Bracket] [Lower Bracket] +// Round 2: [Upper Final] [Lower Final] +// Round 3: [Grand Final] +templ bracket5to6(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { + {{ + s := seriesByNumber(bracket.Series) + conns := connectionsJSON(bracket.Series) + }} +
    +
    + +
    +
    + @seriesCard(season, league, s[1]) + @seriesCard(season, league, s[2]) +
    +
    +
    + @seriesCard(season, league, s[3]) + @seriesCard(season, league, s[4]) +
    +
    +
    + @seriesCard(season, league, s[5]) +
    +
    } -// bracketRounds groups series by round and renders them -templ bracketRounds(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// 7-9 TEAMS FORMAT +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Round 1 (Quarter Finals): [QF1] [QF2] +// Round 2 (Semi Finals): [SF1] [SF2] +// Round 3: [3rd Place] [Grand Final] +templ bracket7to9(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { {{ - // Group series by round - rounds := groupSeriesByRound(bracket.Series) - roundOrder := getRoundOrder(bracket.Format) + s := seriesByNumber(bracket.Series) + conns := connectionsJSON(bracket.Series) }} - for _, roundName := range roundOrder { - if series, ok := rounds[roundName]; ok { -
    -

    - { formatRoundName(roundName) } -

    -
    - for _, s := range series { - @seriesCard(season, league, s) - } +
    +
    + +
    +
    + @seriesCard(season, league, s[1]) + @seriesCard(season, league, s[2]) +
    +
    +
    + @seriesCard(season, league, s[3]) + @seriesCard(season, league, s[4]) +
    +
    +
    + @seriesCard(season, league, s[5]) + @seriesCard(season, league, s[6])
    - } - } +
    +
    } +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// 10-15 TEAMS FORMAT +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// 4 invisible columns, cards placed into specific cells: +// Row 1: EF1(col2) EF2(col3) +// Row 2: QF1(col1) QF2(col4) +// Row 3: SF1(col2) SF2(col3) +// Row 4: PF1(col2) PF2(col3) +// Row 5: 3rd(col2) +// Row 6: GF(col3) +templ bracket10to15(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { + {{ + s := seriesByNumber(bracket.Series) + conns := connectionsJSON(bracket.Series) + }} +
    +
    + +
    + +
    +
    + @seriesCard(season, league, s[3]) + @seriesCard(season, league, s[4]) +
    +
    +
    + +
    + @seriesCard(season, league, s[1]) +
    +
    + @seriesCard(season, league, s[2]) +
    +
    + +
    +
    + @seriesCard(season, league, s[5]) + @seriesCard(season, league, s[6]) +
    +
    +
    + +
    +
    + @seriesCard(season, league, s[7]) + @seriesCard(season, league, s[8]) +
    +
    +
    + +
    +
    + @seriesCard(season, league, s[9]) +
    +
    +
    +
    + +
    +
    +
    + @seriesCard(season, league, s[10]) +
    +
    +
    +
    +
    +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// SHARED COMPONENTS +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) { -
    +
    -
    +
    { series.Label } @seriesFormatBadge(series.MatchesToWin) @@ -73,7 +212,7 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
    if series.MatchesToWin > 1 { -
    +
    { fmt.Sprint(series.Team1Wins) } - { fmt.Sprint(series.Team2Wins) }
    } @@ -88,27 +227,29 @@ templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *i } isTBD := team == nil }} -
    -
    +
    if seed != nil { - + { fmt.Sprint(*seed) } } else { - - + - } if isTBD { TBD } else { - @links.TeamLinkInSeason(team, season, league) +
    + @links.TeamLinkInSeason(team, season, league) +
    if isWinner { - โœ“ + โœ“ } }
    if matchesToWin > 1 { - { fmt.Sprint(wins) } diff --git a/internal/view/seasonsview/playoff_helpers.go b/internal/view/seasonsview/playoff_helpers.go index 3dc7db9..79ae8ec 100644 --- a/internal/view/seasonsview/playoff_helpers.go +++ b/internal/view/seasonsview/playoff_helpers.go @@ -1,76 +1,18 @@ package seasonsview -import "git.haelnorr.com/h/oslstats/internal/db" +import ( + "encoding/json" -// groupSeriesByRound groups playoff series by their round field -func groupSeriesByRound(series []*db.PlayoffSeries) map[string][]*db.PlayoffSeries { - grouped := make(map[string][]*db.PlayoffSeries) + "git.haelnorr.com/h/oslstats/internal/db" +) + +// seriesByNumber returns a map of series_number -> *PlayoffSeries for quick lookup +func seriesByNumber(series []*db.PlayoffSeries) map[int]*db.PlayoffSeries { + m := make(map[int]*db.PlayoffSeries, len(series)) for _, s := range series { - grouped[s.Round] = append(grouped[s.Round], s) - } - return grouped -} - -// getRoundOrder returns the display order of rounds for a given format -func getRoundOrder(format db.PlayoffFormat) []string { - switch format { - case db.PlayoffFormat5to6: - return []string{ - "upper_bracket", - "lower_bracket", - "upper_final", - "lower_final", - "grand_final", - } - case db.PlayoffFormat7to9: - return []string{ - "quarter_final", - "semi_final", - "third_place", - "grand_final", - } - case db.PlayoffFormat10to15: - return []string{ - "qualifying_final", - "elimination_final", - "semi_final", - "preliminary_final", - "third_place", - "grand_final", - } - default: - return nil - } -} - -// formatRoundName converts a round slug to a human-readable name -func formatRoundName(round string) string { - switch round { - case "upper_bracket": - return "Upper Bracket" - case "lower_bracket": - return "Lower Bracket" - case "upper_final": - return "Upper Final" - case "lower_final": - return "Lower Final" - case "quarter_final": - return "Quarter Finals" - case "semi_final": - return "Semi Finals" - case "qualifying_final": - return "Qualifying Finals" - case "elimination_final": - return "Elimination Finals" - case "preliminary_final": - return "Preliminary Finals" - case "third_place": - return "Third Place Playoff" - case "grand_final": - return "Grand Final" - default: - return round + m[s.SeriesNumber] = s } + return m } // formatLabel returns a human-readable format description @@ -86,3 +28,59 @@ func formatLabel(format db.PlayoffFormat) string { return string(format) } } + +// bracketConnection represents a line to draw between two series cards +type bracketConnection struct { + From int `json:"from"` + To int `json:"to"` + Type string `json:"type"` // "winner" or "loser" + ToSide string `json:"toSide,omitempty"` // "left" or "right" โ€” enters side of dest card +} + +// connectionsJSON returns a JSON string of connections for the bracket overlay JS. +// Connections are derived from the series advancement links stored in the DB. +// For the 10-15 format, QF winner lines enter PF cards from the side. +func connectionsJSON(series []*db.PlayoffSeries) string { + // Build a lookup of series ID โ†’ series for resolving advancement targets + byID := make(map[int]*db.PlayoffSeries, len(series)) + for _, s := range series { + byID[s.ID] = s + } + + var conns []bracketConnection + for _, s := range series { + if s.WinnerNextID != nil { + if target, ok := byID[*s.WinnerNextID]; ok { + conn := bracketConnection{ + From: s.SeriesNumber, + To: target.SeriesNumber, + Type: "winner", + } + // QF winners enter PF cards from the side in the 10-15 format + if s.Round == "qualifying_final" && target.Round == "preliminary_final" { + if s.SeriesNumber == 1 { + conn.ToSide = "left" + } else if s.SeriesNumber == 2 { + conn.ToSide = "right" + } + } + conns = append(conns, conn) + } + } + if s.LoserNextID != nil { + if target, ok := byID[*s.LoserNextID]; ok { + conns = append(conns, bracketConnection{ + From: s.SeriesNumber, + To: target.SeriesNumber, + Type: "loser", + }) + } + } + } + + b, err := json.Marshal(conns) + if err != nil { + return "[]" + } + return string(b) +} diff --git a/internal/view/seasonsview/season_league_finals.templ b/internal/view/seasonsview/season_league_finals.templ index 102f6a4..3742fbe 100644 --- a/internal/view/seasonsview/season_league_finals.templ +++ b/internal/view/seasonsview/season_league_finals.templ @@ -13,42 +13,39 @@ templ SeasonLeagueFinalsPage(season *db.Season, league *db.League, bracket *db.P templ SeasonLeagueFinals(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { {{ - status := season.GetStatus() permCache := contexts.Permissions(ctx) canManagePlayoffs := permCache != nil && permCache.HasPermission(permissions.PlayoffsManage) }}
    if bracket != nil { @PlayoffBracketView(season, league, bracket) - } else if status == db.StatusInProgress || status == db.StatusUpcoming { - @finalsRegularSeasonInProgress(season, league, canManagePlayoffs) + } else if canManagePlayoffs { + @finalsNotYetConfigured(season, league) } else { @finalsNotConfigured() }
    } -templ finalsRegularSeasonInProgress(season *db.Season, league *db.League, canManagePlayoffs bool) { +templ finalsNotYetConfigured(season *db.Season, league *db.League) {
    -

    Regular Season in Progress

    +

    No Finals Configured

    - Finals will be available once the regular season is complete. + Set up the playoff bracket for this league.

    - if canManagePlayoffs { - - } +
    } diff --git a/internal/view/seasonsview/status_badge.templ b/internal/view/seasonsview/status_badge.templ index 537e98a..d49d851 100644 --- a/internal/view/seasonsview/status_badge.templ +++ b/internal/view/seasonsview/status_badge.templ @@ -1,7 +1,6 @@ package seasonsview import "git.haelnorr.com/h/oslstats/internal/db" -import "time" // StatusBadge renders a season status badge // Parameters: @@ -10,53 +9,34 @@ import "time" // - useShortLabels: bool - true for "Active/Finals", false for "In Progress/Finals in Progress" templ StatusBadge(season *db.Season, compact bool, useShortLabels bool) { {{ - now := time.Now() + seasonStatus := season.GetStatus() status := "" statusBg := "" - - // Determine status based on dates - if now.Before(season.StartDate) { + + switch seasonStatus { + case db.StatusUpcoming: status = "Upcoming" statusBg = "bg-blue" - } else if !season.FinalsStartDate.IsZero() { - // Finals are scheduled - if !season.FinalsEndDate.IsZero() && now.After(season.FinalsEndDate.Time) { - // Finals have ended - status = "Completed" - statusBg = "bg-teal" - } else if now.After(season.FinalsStartDate.Time) { - // Finals are in progress - if useShortLabels { - status = "Finals" - } else { - status = "Finals in Progress" - } - statusBg = "bg-yellow" - } else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) { - // Regular season ended, finals upcoming - status = "Finals Soon" - statusBg = "bg-peach" - } else { - // Regular season active, finals scheduled for later - if useShortLabels { - status = "Active" - } else { - status = "In Progress" - } - statusBg = "bg-green" - } - } else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) { - // No finals scheduled and regular season ended - status = "Completed" - statusBg = "bg-teal" - } else { - // Regular season active, no finals scheduled + case db.StatusInProgress: if useShortLabels { status = "Active" } else { status = "In Progress" } statusBg = "bg-green" + case db.StatusFinalsSoon: + status = "Finals Soon" + statusBg = "bg-peach" + case db.StatusFinals: + if useShortLabels { + status = "Finals" + } else { + status = "Finals in Progress" + } + statusBg = "bg-yellow" + case db.StatusCompleted: + status = "Completed" + statusBg = "bg-teal" } }} From af42c16fafacb7b314e6884fd2e6a56a900d2bb4 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 15 Mar 2026 12:05:47 +1100 Subject: [PATCH 28/33] series overview added --- ...0315140000_add_playoff_series_schedules.go | 58 ++ internal/db/playoff.go | 221 +++++++ internal/db/playoff_schedule.go | 370 +++++++++++ internal/embedfs/web/css/output.css | 3 + internal/handlers/series_detail.go | 306 +++++++++ internal/handlers/series_schedule.go | 410 ++++++++++++ internal/server/routes.go | 62 ++ .../view/seasonsview/playoff_bracket.templ | 19 +- internal/view/seasonsview/series_detail.templ | 611 ++++++++++++++++++ .../seasonsview/series_match_analysis.templ | 251 +++++++ .../seasonsview/series_match_preview.templ | 319 +++++++++ .../view/seasonsview/series_schedule.templ | 504 +++++++++++++++ 12 files changed, 3133 insertions(+), 1 deletion(-) create mode 100644 internal/db/migrations/20260315140000_add_playoff_series_schedules.go create mode 100644 internal/db/playoff_schedule.go create mode 100644 internal/handlers/series_detail.go create mode 100644 internal/handlers/series_schedule.go create mode 100644 internal/view/seasonsview/series_detail.templ create mode 100644 internal/view/seasonsview/series_match_analysis.templ create mode 100644 internal/view/seasonsview/series_match_preview.templ create mode 100644 internal/view/seasonsview/series_schedule.templ diff --git a/internal/db/migrations/20260315140000_add_playoff_series_schedules.go b/internal/db/migrations/20260315140000_add_playoff_series_schedules.go new file mode 100644 index 0000000..0a23f45 --- /dev/null +++ b/internal/db/migrations/20260315140000_add_playoff_series_schedules.go @@ -0,0 +1,58 @@ +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 { + _, err := conn.NewCreateTable(). + Model((*db.PlayoffSeriesSchedule)(nil)). + IfNotExists(). + ForeignKey(`("series_id") REFERENCES "playoff_series" ("id") ON DELETE CASCADE`). + ForeignKey(`("proposed_by_team_id") REFERENCES "teams" ("id")`). + ForeignKey(`("accepted_by_team_id") REFERENCES "teams" ("id")`). + Exec(ctx) + if err != nil { + return err + } + + // Create index on series_id for faster lookups + _, err = conn.NewCreateIndex(). + Model((*db.PlayoffSeriesSchedule)(nil)). + Index("idx_playoff_series_schedules_series_id"). + Column("series_id"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Create index on status for filtering + _, err = conn.NewCreateIndex(). + Model((*db.PlayoffSeriesSchedule)(nil)). + Index("idx_playoff_series_schedules_status"). + Column("status"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + return nil + }, + // DOWN migration + func(ctx context.Context, conn *bun.DB) error { + _, err := conn.NewDropTable(). + Model((*db.PlayoffSeriesSchedule)(nil)). + IfExists(). + Exec(ctx) + return err + }, + ) +} diff --git a/internal/db/playoff.go b/internal/db/playoff.go index 01a2bdd..cc9965d 100644 --- a/internal/db/playoff.go +++ b/internal/db/playoff.go @@ -324,3 +324,224 @@ func AutoForfeitUnplayedFixtures( return len(unplayed), nil } + +// GetPlayoffSeriesByID retrieves a single playoff series with all relations needed +// for the series detail page. +func GetPlayoffSeriesByID( + ctx context.Context, + tx bun.Tx, + seriesID int, +) (*PlayoffSeries, error) { + series := new(PlayoffSeries) + err := tx.NewSelect(). + Model(series). + Where("ps.id = ?", seriesID). + Relation("Bracket"). + Relation("Bracket.Season"). + Relation("Bracket.League"). + Relation("Bracket.Series"). + Relation("Team1"). + Relation("Team2"). + Relation("Winner"). + Relation("Loser"). + Relation("Matches", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Order("pm.match_number ASC") + }). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil, nil + } + return nil, errors.Wrap(err, "tx.NewSelect") + } + return series, nil +} + +// CanScheduleSeries checks if the user is a manager of one of the teams in the series. +// Returns (canSchedule, teamID) where teamID is the team the user manages (0 if not a manager). +// Both teams must be assigned for scheduling to be possible. +func CanScheduleSeries( + ctx context.Context, + tx bun.Tx, + series *PlayoffSeries, + user *User, +) (bool, int, error) { + if user == nil || user.Player == nil { + return false, 0, nil + } + if series.Team1ID == nil || series.Team2ID == nil { + return false, 0, nil + } + + roster := new(TeamRoster) + err := tx.NewSelect(). + Model(roster). + Column("team_id", "is_manager"). + Where("team_id IN (?)", bun.In([]int{*series.Team1ID, *series.Team2ID})). + Where("season_id = ?", series.Bracket.SeasonID). + Where("league_id = ?", series.Bracket.LeagueID). + Where("player_id = ?", user.Player.ID). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return false, 0, nil + } + return false, 0, errors.Wrap(err, "tx.NewSelect") + } + if !roster.IsManager { + return false, 0, nil + } + return true, roster.TeamID, nil +} + +// GetSeriesTeamRosters returns rosters for both teams in a series. +// Returns map["team1"|"team2"] -> []*PlayerWithPlayStatus +func GetSeriesTeamRosters( + ctx context.Context, + tx bun.Tx, + series *PlayoffSeries, +) (map[string][]*PlayerWithPlayStatus, error) { + if series == nil { + return nil, errors.New("series cannot be nil") + } + + rosters := map[string][]*PlayerWithPlayStatus{} + + if series.Team1ID != nil { + team1Rosters := []*TeamRoster{} + err := tx.NewSelect(). + Model(&team1Rosters). + Where("tr.team_id = ?", *series.Team1ID). + Where("tr.season_id = ?", series.Bracket.SeasonID). + Where("tr.league_id = ?", series.Bracket.LeagueID). + Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Relation("User") + }). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect team1 roster") + } + for _, tr := range team1Rosters { + rosters["team1"] = append(rosters["team1"], &PlayerWithPlayStatus{ + Player: tr.Player, + Played: false, + IsManager: tr.IsManager, + }) + } + } + + if series.Team2ID != nil { + team2Rosters := []*TeamRoster{} + err := tx.NewSelect(). + Model(&team2Rosters). + Where("tr.team_id = ?", *series.Team2ID). + Where("tr.season_id = ?", series.Bracket.SeasonID). + Where("tr.league_id = ?", series.Bracket.LeagueID). + Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Relation("User") + }). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect team2 roster") + } + for _, tr := range team2Rosters { + rosters["team2"] = append(rosters["team2"], &PlayerWithPlayStatus{ + Player: tr.Player, + Played: false, + IsManager: tr.IsManager, + }) + } + } + + return rosters, nil +} + +// ComputeSeriesPreview computes standings comparison data for the two teams in a series. +// Uses the same logic as ComputeMatchPreview but takes a series instead of a fixture. +func ComputeSeriesPreview( + ctx context.Context, + tx bun.Tx, + series *PlayoffSeries, +) (*MatchPreviewData, error) { + if series == nil || series.Bracket == nil { + return nil, errors.New("series and bracket cannot be nil") + } + + seasonID := series.Bracket.SeasonID + leagueID := series.Bracket.LeagueID + + // Get all teams in this season+league + allTeams, err := GetTeamsForSeasonLeague(ctx, tx, seasonID, leagueID) + if err != nil { + return nil, errors.Wrap(err, "GetTeamsForSeasonLeague") + } + + // Get all allocated fixtures for the season+league + allFixtures, err := GetAllocatedFixtures(ctx, tx, seasonID, leagueID) + if err != nil { + return nil, errors.Wrap(err, "GetAllocatedFixtures") + } + + // Get finalized results + allFixtureIDs := make([]int, len(allFixtures)) + for i, f := range allFixtures { + allFixtureIDs[i] = f.ID + } + allResultMap, err := GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs) + if err != nil { + return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures") + } + + // Get accepted schedules for ordering recent games + allScheduleMap, err := GetAcceptedSchedulesForFixtures(ctx, tx, allFixtureIDs) + if err != nil { + return nil, errors.Wrap(err, "GetAcceptedSchedulesForFixtures") + } + + // Compute leaderboard + leaderboard := ComputeLeaderboard(allTeams, allFixtures, allResultMap) + + preview := &MatchPreviewData{ + TotalTeams: len(leaderboard), + } + + team1ID := 0 + team2ID := 0 + if series.Team1ID != nil { + team1ID = *series.Team1ID + } + if series.Team2ID != nil { + team2ID = *series.Team2ID + } + + for _, entry := range leaderboard { + if entry.Team.ID == team1ID { + preview.HomePosition = entry.Position + preview.HomeRecord = entry.Record + } + if entry.Team.ID == team2ID { + preview.AwayPosition = entry.Position + preview.AwayRecord = entry.Record + } + } + if preview.HomeRecord == nil { + preview.HomeRecord = &TeamRecord{} + } + if preview.AwayRecord == nil { + preview.AwayRecord = &TeamRecord{} + } + + // Compute recent games (last 5) for each team + if team1ID > 0 { + preview.HomeRecentGames = ComputeRecentGames( + team1ID, allFixtures, allResultMap, allScheduleMap, 5, + ) + } + if team2ID > 0 { + preview.AwayRecentGames = ComputeRecentGames( + team2ID, allFixtures, allResultMap, allScheduleMap, 5, + ) + } + + return preview, nil +} diff --git a/internal/db/playoff_schedule.go b/internal/db/playoff_schedule.go new file mode 100644 index 0000000..8f66dc0 --- /dev/null +++ b/internal/db/playoff_schedule.go @@ -0,0 +1,370 @@ +package db + +import ( + "context" + "time" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// PlayoffSeriesSchedule represents a schedule proposal for a playoff series. +// Mirrors FixtureSchedule but references a series instead of a fixture. +type PlayoffSeriesSchedule struct { + bun.BaseModel `bun:"table:playoff_series_schedules,alias:pss"` + + ID int `bun:"id,pk,autoincrement"` + SeriesID int `bun:",notnull"` + ScheduledTime *time.Time `bun:"scheduled_time"` + ProposedByTeamID int `bun:",notnull"` + AcceptedByTeamID *int `bun:"accepted_by_team_id"` + Status ScheduleStatus `bun:",notnull,default:'pending'"` + RescheduleReason *string `bun:"reschedule_reason"` + CreatedAt int64 `bun:",notnull"` + UpdatedAt *int64 `bun:"updated_at"` + + Series *PlayoffSeries `bun:"rel:belongs-to,join:series_id=id"` + ProposedBy *Team `bun:"rel:belongs-to,join:proposed_by_team_id=id"` + AcceptedBy *Team `bun:"rel:belongs-to,join:accepted_by_team_id=id"` +} + +// GetCurrentSeriesSchedule returns the most recent schedule record for a series. +// Returns nil, nil if no schedule exists. +func GetCurrentSeriesSchedule(ctx context.Context, tx bun.Tx, seriesID int) (*PlayoffSeriesSchedule, error) { + schedule := new(PlayoffSeriesSchedule) + err := tx.NewSelect(). + Model(schedule). + Where("series_id = ?", seriesID). + Order("created_at DESC", "id DESC"). + Relation("ProposedBy"). + Relation("AcceptedBy"). + Limit(1). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil, nil + } + return nil, errors.Wrap(err, "tx.NewSelect") + } + return schedule, nil +} + +// GetSeriesScheduleHistory returns all schedule records for a series in chronological order +func GetSeriesScheduleHistory(ctx context.Context, tx bun.Tx, seriesID int) ([]*PlayoffSeriesSchedule, error) { + schedules, err := GetList[PlayoffSeriesSchedule](tx). + Where("series_id = ?", seriesID). + Order("created_at ASC", "id ASC"). + Relation("ProposedBy"). + Relation("AcceptedBy"). + GetAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "GetList") + } + return schedules, nil +} + +// ProposeSeriesSchedule creates a new pending schedule proposal for a series. +// Cannot propose on cancelled or accepted schedules. +func ProposeSeriesSchedule( + ctx context.Context, + tx bun.Tx, + seriesID, proposedByTeamID int, + scheduledTime time.Time, + audit *AuditMeta, +) (*PlayoffSeriesSchedule, error) { + current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return nil, errors.Wrap(err, "GetCurrentSeriesSchedule") + } + if current != nil { + switch current.Status { + case ScheduleStatusCancelled: + return nil, BadRequest("cannot propose a new time for a cancelled series") + case ScheduleStatusAccepted: + return nil, BadRequest("series already has an accepted schedule; use reschedule instead") + case ScheduleStatusPending: + // Supersede existing pending record + now := time.Now().Unix() + current.Status = ScheduleStatusRescheduled + current.UpdatedAt = &now + err = UpdateByID(tx, current.ID, current). + Column("status", "updated_at"). + Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "UpdateByID") + } + // rejected, rescheduled, postponed, withdrawn are terminal โ€” safe to create a new proposal + } + } + + schedule := &PlayoffSeriesSchedule{ + SeriesID: seriesID, + ScheduledTime: &scheduledTime, + ProposedByTeamID: proposedByTeamID, + Status: ScheduleStatusPending, + CreatedAt: time.Now().Unix(), + } + err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{ + Action: "series_schedule.propose", + ResourceType: "playoff_series_schedule", + ResourceID: seriesID, + Details: map[string]any{ + "series_id": seriesID, + "proposed_by": proposedByTeamID, + "scheduled_time": scheduledTime, + }, + }).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "Insert") + } + return schedule, nil +} + +// AcceptSeriesSchedule accepts a pending schedule proposal. +// The acceptedByTeamID must be the other team (not the proposer). +func AcceptSeriesSchedule( + ctx context.Context, + tx bun.Tx, + scheduleID, acceptedByTeamID int, + audit *AuditMeta, +) error { + schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx) + if err != nil { + return errors.Wrap(err, "GetByID") + } + if schedule.Status != ScheduleStatusPending { + return BadRequest("schedule is not in pending status") + } + if schedule.ProposedByTeamID == acceptedByTeamID { + return BadRequest("cannot accept your own proposal") + } + + now := time.Now().Unix() + schedule.AcceptedByTeamID = &acceptedByTeamID + schedule.Status = ScheduleStatusAccepted + schedule.UpdatedAt = &now + err = UpdateByID(tx, schedule.ID, schedule). + Column("accepted_by_team_id", "status", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "series_schedule.accept", + ResourceType: "playoff_series_schedule", + ResourceID: scheduleID, + Details: map[string]any{ + "series_id": schedule.SeriesID, + "accepted_by": acceptedByTeamID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// RejectSeriesSchedule rejects a pending schedule proposal. +func RejectSeriesSchedule( + ctx context.Context, + tx bun.Tx, + scheduleID int, + audit *AuditMeta, +) error { + schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx) + if err != nil { + return errors.Wrap(err, "GetByID") + } + if schedule.Status != ScheduleStatusPending { + return BadRequest("schedule is not in pending status") + } + + now := time.Now().Unix() + schedule.Status = ScheduleStatusRejected + schedule.UpdatedAt = &now + err = UpdateByID(tx, schedule.ID, schedule). + Column("status", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "series_schedule.reject", + ResourceType: "playoff_series_schedule", + ResourceID: scheduleID, + Details: map[string]any{ + "series_id": schedule.SeriesID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// RescheduleSeriesSchedule marks the current accepted schedule as rescheduled +// and creates a new pending proposal with the new time. +func RescheduleSeriesSchedule( + ctx context.Context, + tx bun.Tx, + seriesID, proposedByTeamID int, + newTime time.Time, + reason string, + audit *AuditMeta, +) (*PlayoffSeriesSchedule, error) { + current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return nil, errors.Wrap(err, "GetCurrentSeriesSchedule") + } + if current == nil || current.Status != ScheduleStatusAccepted { + return nil, BadRequest("no accepted schedule to reschedule") + } + + now := time.Now().Unix() + current.Status = ScheduleStatusRescheduled + current.RescheduleReason = &reason + current.UpdatedAt = &now + err = UpdateByID(tx, current.ID, current). + Column("status", "reschedule_reason", "updated_at"). + Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "UpdateByID") + } + + // Create new pending proposal + schedule := &PlayoffSeriesSchedule{ + SeriesID: seriesID, + ScheduledTime: &newTime, + ProposedByTeamID: proposedByTeamID, + Status: ScheduleStatusPending, + CreatedAt: time.Now().Unix(), + } + err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{ + Action: "series_schedule.reschedule", + ResourceType: "playoff_series_schedule", + ResourceID: seriesID, + Details: map[string]any{ + "series_id": seriesID, + "proposed_by": proposedByTeamID, + "new_time": newTime, + "reason": reason, + "old_schedule_id": current.ID, + }, + }).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "Insert") + } + return schedule, nil +} + +// PostponeSeriesSchedule marks the current accepted schedule as postponed. +// This is a terminal state โ€” a new proposal can be created afterwards. +func PostponeSeriesSchedule( + ctx context.Context, + tx bun.Tx, + seriesID int, + reason string, + audit *AuditMeta, +) error { + current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return errors.Wrap(err, "GetCurrentSeriesSchedule") + } + if current == nil || current.Status != ScheduleStatusAccepted { + return BadRequest("no accepted schedule to postpone") + } + + now := time.Now().Unix() + current.Status = ScheduleStatusPostponed + current.RescheduleReason = &reason + current.UpdatedAt = &now + err = UpdateByID(tx, current.ID, current). + Column("status", "reschedule_reason", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "series_schedule.postpone", + ResourceType: "playoff_series_schedule", + ResourceID: seriesID, + Details: map[string]any{ + "series_id": seriesID, + "reason": reason, + "old_schedule_id": current.ID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// WithdrawSeriesSchedule allows the proposer to withdraw their pending proposal. +// Only the team that proposed can withdraw it. +func WithdrawSeriesSchedule( + ctx context.Context, + tx bun.Tx, + scheduleID, withdrawByTeamID int, + audit *AuditMeta, +) error { + schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx) + if err != nil { + return errors.Wrap(err, "GetByID") + } + if schedule.Status != ScheduleStatusPending { + return BadRequest("schedule is not in pending status") + } + if schedule.ProposedByTeamID != withdrawByTeamID { + return BadRequest("only the proposing team can withdraw their proposal") + } + + now := time.Now().Unix() + schedule.Status = ScheduleStatusWithdrawn + schedule.UpdatedAt = &now + err = UpdateByID(tx, schedule.ID, schedule). + Column("status", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "series_schedule.withdraw", + ResourceType: "playoff_series_schedule", + ResourceID: scheduleID, + Details: map[string]any{ + "series_id": schedule.SeriesID, + "withdrawn_by": withdrawByTeamID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// CancelSeriesSchedule marks the current schedule as cancelled. This is a terminal state. +// Requires playoffs.manage permission (moderator-level). +func CancelSeriesSchedule( + ctx context.Context, + tx bun.Tx, + seriesID int, + reason string, + audit *AuditMeta, +) error { + current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return errors.Wrap(err, "GetCurrentSeriesSchedule") + } + if current == nil { + return BadRequest("no schedule to cancel") + } + if current.Status.IsTerminal() { + return BadRequest("schedule is already in a terminal state") + } + + now := time.Now().Unix() + current.Status = ScheduleStatusCancelled + current.RescheduleReason = &reason + current.UpdatedAt = &now + err = UpdateByID(tx, current.ID, current). + Column("status", "reschedule_reason", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "series_schedule.cancel", + ResourceType: "playoff_series_schedule", + ResourceID: seriesID, + Details: map[string]any{ + "series_id": seriesID, + "reason": reason, + "schedule_id": current.ID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 6a84a71..bfc81c6 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -553,6 +553,9 @@ .w-14 { width: calc(var(--spacing) * 14); } + .w-16 { + width: calc(var(--spacing) * 16); + } .w-20 { width: calc(var(--spacing) * 20); } diff --git a/internal/handlers/series_detail.go b/internal/handlers/series_detail.go new file mode 100644 index 0000000..7798411 --- /dev/null +++ b/internal/handlers/series_detail.go @@ -0,0 +1,306 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/respond" + "git.haelnorr.com/h/oslstats/internal/throw" + "git.haelnorr.com/h/oslstats/internal/view/seasonsview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// SeriesDetailPage redirects to the default tab (overview) +func SeriesDetailPage( + 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 + } + + http.Redirect(w, r, fmt.Sprintf("/series/%d/overview", seriesID), http.StatusSeeOther) + }) +} + +// SeriesDetailOverviewPage renders the overview tab of the series detail page +func SeriesDetailOverviewPage( + 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 currentSchedule *db.PlayoffSeriesSchedule + var canSchedule bool + var userTeamID int + var rosters map[string][]*db.PlayerWithPlayStatus + + 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 + } + + currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule") + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err = db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + + rosters, err = db.GetSeriesTeamRosters(ctx, tx, series) + if err != nil { + return false, errors.Wrap(err, "db.GetSeriesTeamRosters") + } + + return true, nil + }); !ok { + return + } + + if r.Method == "GET" { + renderSafely(seasonsview.SeriesDetailOverviewPage( + series, currentSchedule, canSchedule, userTeamID, rosters, + ), s, r, w) + } else { + renderSafely(seasonsview.SeriesDetailOverviewContent( + series, currentSchedule, canSchedule, userTeamID, rosters, + ), s, r, w) + } + }) +} + +// SeriesDetailPreviewPage renders the match preview tab of the series detail page +func SeriesDetailPreviewPage( + 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 currentSchedule *db.PlayoffSeriesSchedule + var rosters map[string][]*db.PlayerWithPlayStatus + var previewData *db.MatchPreviewData + + 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 + } + + currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule") + } + + rosters, err = db.GetSeriesTeamRosters(ctx, tx, series) + if err != nil { + return false, errors.Wrap(err, "db.GetSeriesTeamRosters") + } + + previewData, err = db.ComputeSeriesPreview(ctx, tx, series) + if err != nil { + return false, errors.Wrap(err, "db.ComputeSeriesPreview") + } + + return true, nil + }); !ok { + return + } + + // If completed, redirect to analysis instead + if series.Status == db.SeriesStatusCompleted { + if r.Method == "GET" { + http.Redirect(w, r, fmt.Sprintf("/series/%d/analysis", seriesID), http.StatusSeeOther) + } else { + respond.HXRedirect(w, "/series/%d/analysis", seriesID) + } + return + } + + if r.Method == "GET" { + renderSafely(seasonsview.SeriesDetailPreviewPage( + series, currentSchedule, rosters, previewData, + ), s, r, w) + } else { + renderSafely(seasonsview.SeriesDetailPreviewContent( + series, rosters, previewData, + ), s, r, w) + } + }) +} + +// SeriesDetailAnalysisPage renders the match analysis tab of the series detail page +func SeriesDetailAnalysisPage( + 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 currentSchedule *db.PlayoffSeriesSchedule + var rosters map[string][]*db.PlayerWithPlayStatus + var previewData *db.MatchPreviewData + + 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 + } + + currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule") + } + + rosters, err = db.GetSeriesTeamRosters(ctx, tx, series) + if err != nil { + return false, errors.Wrap(err, "db.GetSeriesTeamRosters") + } + + previewData, err = db.ComputeSeriesPreview(ctx, tx, series) + if err != nil { + return false, errors.Wrap(err, "db.ComputeSeriesPreview") + } + + return true, nil + }); !ok { + return + } + + // If not completed, redirect to preview instead + if series.Status != db.SeriesStatusCompleted { + if r.Method == "GET" { + http.Redirect(w, r, fmt.Sprintf("/series/%d/preview", seriesID), http.StatusSeeOther) + } else { + respond.HXRedirect(w, "/series/%d/preview", seriesID) + } + return + } + + if r.Method == "GET" { + renderSafely(seasonsview.SeriesDetailAnalysisPage( + series, currentSchedule, rosters, previewData, + ), s, r, w) + } else { + renderSafely(seasonsview.SeriesDetailAnalysisContent( + series, rosters, previewData, + ), s, r, w) + } + }) +} + +// SeriesDetailSchedulePage renders the schedule tab of the series detail page +func SeriesDetailSchedulePage( + 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 currentSchedule *db.PlayoffSeriesSchedule + var history []*db.PlayoffSeriesSchedule + var canSchedule bool + var userTeamID int + + 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 + } + + currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule") + } + + history, err = db.GetSeriesScheduleHistory(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetSeriesScheduleHistory") + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err = db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + + return true, nil + }); !ok { + return + } + + // If completed, redirect to overview + if series.Status == db.SeriesStatusCompleted { + if r.Method == "GET" { + http.Redirect(w, r, fmt.Sprintf("/series/%d/overview", seriesID), http.StatusSeeOther) + } else { + respond.HXRedirect(w, "/series/%d/overview", seriesID) + } + return + } + + if r.Method == "GET" { + renderSafely(seasonsview.SeriesDetailSchedulePage( + series, currentSchedule, history, canSchedule, userTeamID, + ), s, r, w) + } else { + renderSafely(seasonsview.SeriesDetailScheduleContent( + series, currentSchedule, history, canSchedule, userTeamID, + ), s, r, w) + } + }) +} diff --git a/internal/handlers/series_schedule.go b/internal/handlers/series_schedule.go new file mode 100644 index 0000000..0fd1b6f --- /dev/null +++ b/internal/handlers/series_schedule.go @@ -0,0 +1,410 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + "time" + + "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" + "git.haelnorr.com/h/timefmt" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// ProposeSeriesSchedule handles POST /series/{series_id}/schedule +func ProposeSeriesSchedule( + 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 + } + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash(). + DayNumeric2().T().Hour24().Colon().Minute().Build() + aest, _ := time.LoadLocation("Australia/Sydney") + scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).Value + + if !getter.ValidateAndNotify(s, w, r) { + return + } + + 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 + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to propose a schedule", nil) + return false, nil + } + + _, err = db.ProposeSeriesSchedule(ctx, tx, seriesID, userTeamID, scheduledTime, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Propose", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.ProposeSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.SuccessWithDelay(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} + +// AcceptSeriesSchedule handles POST /series/{series_id}/schedule/{schedule_id}/accept +func AcceptSeriesSchedule( + 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 + } + scheduleID, err := strconv.Atoi(r.PathValue("schedule_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid schedule ID", err) + return + } + + 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 + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to accept a schedule", nil) + return false, nil + } + + err = db.AcceptSeriesSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Accept", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.AcceptSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Schedule Accepted", "The series time has been confirmed.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} + +// RejectSeriesSchedule handles POST /series/{series_id}/schedule/{schedule_id}/reject +func RejectSeriesSchedule( + 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 + } + scheduleID, err := strconv.Atoi(r.PathValue("schedule_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid schedule ID", err) + return + } + + 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 + } + + user := db.CurrentUser(ctx) + canSchedule, _, err := db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to reject a schedule", nil) + return false, nil + } + + err = db.RejectSeriesSchedule(ctx, tx, scheduleID, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Reject", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.RejectSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Schedule Rejected", "The proposed time has been rejected.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} + +// PostponeSeriesSchedule handles POST /series/{series_id}/schedule/postpone +func PostponeSeriesSchedule( + 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 + } + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + reason := getter.String("reschedule_reason").TrimSpace().Required().Value + if !getter.ValidateAndNotify(s, w, r) { + return + } + + 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 + } + + user := db.CurrentUser(ctx) + canSchedule, _, err := db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to postpone a series", nil) + return false, nil + } + + err = db.PostponeSeriesSchedule(ctx, tx, seriesID, reason, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Postpone", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.PostponeSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Series Postponed", "The series has been postponed.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} + +// RescheduleSeriesHandler handles POST /series/{series_id}/schedule/reschedule +func RescheduleSeriesHandler( + 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 + } + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash(). + DayNumeric2().T().Hour24().Colon().Minute().Build() + aest, _ := time.LoadLocation("Australia/Sydney") + scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value + reason := getter.String("reschedule_reason").TrimSpace().Required().Value + + if !getter.ValidateAndNotify(s, w, r) { + return + } + + 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 + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to reschedule a series", nil) + return false, nil + } + + _, err = db.RescheduleSeriesSchedule(ctx, tx, seriesID, userTeamID, scheduledTime, reason, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Reschedule", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.RescheduleSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Series Rescheduled", "The new proposed time has been submitted.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} + +// WithdrawSeriesScheduleHandler handles POST /series/{series_id}/schedule/{schedule_id}/withdraw +func WithdrawSeriesScheduleHandler( + 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 + } + scheduleID, err := strconv.Atoi(r.PathValue("schedule_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid schedule ID", err) + return + } + + 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 + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to withdraw a proposal", nil) + return false, nil + } + + err = db.WithdrawSeriesSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Withdraw", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.WithdrawSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Proposal Withdrawn", "Your proposed time has been withdrawn.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} + +// CancelSeriesScheduleHandler handles POST /series/{series_id}/schedule/cancel +// This is a moderator-only action that requires playoffs.manage permission. +func CancelSeriesScheduleHandler( + 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 + } + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + reason := getter.String("reschedule_reason").TrimSpace().Required().Value + if !getter.ValidateAndNotify(s, w, r) { + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + err := db.CancelSeriesSchedule(ctx, tx, seriesID, reason, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Cancel", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.CancelSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Schedule Cancelled", "The series schedule has been cancelled.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 3b39c2d..3bb0aae 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -329,6 +329,68 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.ForfeitFixture(s, conn)), }, + // Series detail page routes + { + Path: "/series/{series_id}", + Method: hws.MethodGET, + Handler: handlers.SeriesDetailPage(s, conn), + }, + { + Path: "/series/{series_id}/overview", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.SeriesDetailOverviewPage(s, conn), + }, + { + Path: "/series/{series_id}/preview", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.SeriesDetailPreviewPage(s, conn), + }, + { + Path: "/series/{series_id}/analysis", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.SeriesDetailAnalysisPage(s, conn), + }, + { + Path: "/series/{series_id}/scheduling", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.SeriesDetailSchedulePage(s, conn), + }, + // Series scheduling routes + { + Path: "/series/{series_id}/schedule", + Method: hws.MethodPOST, + Handler: handlers.ProposeSeriesSchedule(s, conn), + }, + { + Path: "/series/{series_id}/schedule/{schedule_id}/accept", + Method: hws.MethodPOST, + Handler: handlers.AcceptSeriesSchedule(s, conn), + }, + { + Path: "/series/{series_id}/schedule/{schedule_id}/reject", + Method: hws.MethodPOST, + Handler: handlers.RejectSeriesSchedule(s, conn), + }, + { + Path: "/series/{series_id}/schedule/{schedule_id}/withdraw", + Method: hws.MethodPOST, + Handler: handlers.WithdrawSeriesScheduleHandler(s, conn), + }, + { + Path: "/series/{series_id}/schedule/postpone", + Method: hws.MethodPOST, + Handler: handlers.PostponeSeriesSchedule(s, conn), + }, + { + Path: "/series/{series_id}/schedule/reschedule", + Method: hws.MethodPOST, + Handler: handlers.RescheduleSeriesHandler(s, conn), + }, + { + Path: "/series/{series_id}/schedule/cancel", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.CancelSeriesScheduleHandler(s, conn)), + }, } playerRoutes := []hws.Route{ diff --git a/internal/view/seasonsview/playoff_bracket.templ b/internal/view/seasonsview/playoff_bracket.templ index fe33d3c..b451156 100644 --- a/internal/view/seasonsview/playoff_bracket.templ +++ b/internal/view/seasonsview/playoff_bracket.templ @@ -44,6 +44,15 @@ templ PlayoffBracketView(season *db.Season, league *db.League, bracket *db.Playo
    + } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -189,11 +198,19 @@ templ bracket10to15(season *db.Season, league *db.League, bracket *db.PlayoffBra // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) { + {{ + hasTeams := series.Team1 != nil || series.Team2 != nil + seriesURL := fmt.Sprintf("/series/%d", series.ID) + }}
    diff --git a/internal/view/seasonsview/series_detail.templ b/internal/view/seasonsview/series_detail.templ new file mode 100644 index 0000000..fb4a911 --- /dev/null +++ b/internal/view/seasonsview/series_detail.templ @@ -0,0 +1,611 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/permissions" +import "git.haelnorr.com/h/oslstats/internal/contexts" +import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" +import "sort" +import "strings" + +// seriesTeamName returns a display name for a team in the series, or "TBD" if nil +func seriesTeamName(team *db.Team) string { + if team == nil { + return "TBD" + } + return team.Name +} + +// seriesTeamShortName returns a short name for a team in the series, or "TBD" if nil +func seriesTeamShortName(team *db.Team) string { + if team == nil { + return "TBD" + } + return team.ShortName +} + +// roundDisplayName converts a round slug to a human-readable name +func roundDisplayName(round string) string { + switch round { + case "upper_bracket": + return "Upper Bracket" + case "lower_bracket": + return "Lower Bracket" + case "upper_final": + return "Upper Final" + case "lower_final": + return "Lower Final" + case "quarter_final": + return "Quarter Final" + case "semi_final": + return "Semi Final" + case "elimination_final": + return "Elimination Final" + case "qualifying_final": + return "Qualifying Final" + case "preliminary_final": + return "Preliminary Final" + case "third_place": + return "Third Place Playoff" + case "grand_final": + return "Grand Final" + default: + return strings.ReplaceAll(round, "_", " ") + } +} + +// SeriesDetailLayout renders the series detail page layout with header and +// tab navigation. Tab content is rendered as children. +templ SeriesDetailLayout(activeTab string, series *db.PlayoffSeries, schedule *db.PlayoffSeriesSchedule) { + {{ + backURL := fmt.Sprintf("/seasons/%s/leagues/%s/finals", + series.Bracket.Season.ShortName, series.Bracket.League.ShortName) + isCompleted := series.Status == db.SeriesStatusCompleted + team1Name := seriesTeamName(series.Team1) + team2Name := seriesTeamName(series.Team2) + boLabel := fmt.Sprintf("BO%d", series.MatchesToWin*2-1) + }} + @baseview.Layout(fmt.Sprintf("%s โ€” %s vs %s", series.Label, team1Name, team2Name)) { +
    + +
    +
    +
    +
    +
    +

    + { team1Name } + vs + { team2Name } +

    +
    +
    + + { series.Label } + + + { boLabel } + + if series.Team1Seed != nil || series.Team2Seed != nil { + + if series.Team1Seed != nil && series.Team2Seed != nil { + Seed { fmt.Sprint(*series.Team1Seed) } vs { fmt.Sprint(*series.Team2Seed) } + } else if series.Team1Seed != nil { + Seed { fmt.Sprint(*series.Team1Seed) } + } else if series.Team2Seed != nil { + Seed { fmt.Sprint(*series.Team2Seed) } + } + + } + + { series.Bracket.Season.Name } โ€” { series.Bracket.League.Name } + +
    +
    + + Back to Bracket + +
    +
    + + +
    + +
    + { children... } +
    +
    + + } +} + +templ seriesTabItem(section string, label string, activeTab string, series *db.PlayoffSeries) { + {{ + isActive := section == activeTab + baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2" + activeClasses := "border-blue text-blue font-semibold" + inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2" + url := fmt.Sprintf("/series/%d/%s", series.ID, section) + }} +
  • + + { label } + +
  • +} + +// ==================== Full page wrappers (for GET requests / direct navigation) ==================== + +templ SeriesDetailOverviewPage( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + canSchedule bool, + userTeamID int, + rosters map[string][]*db.PlayerWithPlayStatus, +) { + @SeriesDetailLayout("overview", series, currentSchedule) { + @SeriesDetailOverviewContent(series, currentSchedule, canSchedule, userTeamID, rosters) + } +} + +templ SeriesDetailPreviewPage( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + rosters map[string][]*db.PlayerWithPlayStatus, + previewData *db.MatchPreviewData, +) { + @SeriesDetailLayout("preview", series, currentSchedule) { + @SeriesDetailPreviewContent(series, rosters, previewData) + } +} + +templ SeriesDetailAnalysisPage( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + rosters map[string][]*db.PlayerWithPlayStatus, + previewData *db.MatchPreviewData, +) { + @SeriesDetailLayout("analysis", series, currentSchedule) { + @SeriesDetailAnalysisContent(series, rosters, previewData) + } +} + +templ SeriesDetailSchedulePage( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + history []*db.PlayoffSeriesSchedule, + canSchedule bool, + userTeamID int, +) { + @SeriesDetailLayout("scheduling", series, currentSchedule) { + @SeriesDetailScheduleContent(series, currentSchedule, history, canSchedule, userTeamID) + } +} + +// ==================== Tab content components (for POST requests / HTMX swaps) ==================== + +templ SeriesDetailOverviewContent( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + canSchedule bool, + userTeamID int, + rosters map[string][]*db.PlayerWithPlayStatus, +) { + {{ + permCache := contexts.Permissions(ctx) + canManage := permCache.HasPermission(permissions.PlayoffsManage) + _ = canManage + }} + @seriesOverviewTab(series, currentSchedule, rosters, canSchedule, userTeamID) +} + +templ SeriesDetailPreviewContent( + series *db.PlayoffSeries, + rosters map[string][]*db.PlayerWithPlayStatus, + previewData *db.MatchPreviewData, +) { + @seriesMatchPreviewTab(series, rosters, previewData) +} + +templ SeriesDetailAnalysisContent( + series *db.PlayoffSeries, + rosters map[string][]*db.PlayerWithPlayStatus, + previewData *db.MatchPreviewData, +) { + @seriesMatchAnalysisTab(series, rosters, previewData) +} + +templ SeriesDetailScheduleContent( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + history []*db.PlayoffSeriesSchedule, + canSchedule bool, + userTeamID int, +) { + {{ + permCache := contexts.Permissions(ctx) + canManage := permCache.HasPermission(permissions.PlayoffsManage) + }} + @seriesScheduleTab(series, currentSchedule, history, canSchedule, canManage, userTeamID) +} + +// ==================== Overview Tab ==================== +templ seriesOverviewTab( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + rosters map[string][]*db.PlayerWithPlayStatus, + canSchedule bool, + userTeamID int, +) { +
    + +
    +
    + @seriesScoreDisplay(series) +
    +
    + @seriesScheduleSummary(series, currentSchedule) +
    +
    + + + if len(series.Matches) > 0 { + @seriesMatchList(series) + } + + + @seriesContextCard(series) + + +
    + if series.Team1 != nil { + @seriesTeamSection(series.Team1, rosters["team1"], series.Bracket.Season, series.Bracket.League) + } + if series.Team2 != nil { + @seriesTeamSection(series.Team2, rosters["team2"], series.Bracket.Season, series.Bracket.League) + } +
    +
    +} + +templ seriesScoreDisplay(series *db.PlayoffSeries) { + {{ + isCompleted := series.Status == db.SeriesStatusCompleted + team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID + team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID + isBye := series.Status == db.SeriesStatusBye + }} +
    +
    +

    Series Score

    +
    + @seriesStatusBadge(series.Status) + @seriesFormatBadge(series.MatchesToWin) +
    +
    +
    + if isBye { +
    +

    Bye โ€” team advances automatically

    +
    + } else if series.Team1 == nil && series.Team2 == nil { +
    +

    Teams not yet determined

    +
    + } else { +
    +
    + if team1Won { + 🏆 + } + if series.Team1 != nil && series.Team1.Color != "" { + + { seriesTeamShortName(series.Team1) } + + } else { + + { seriesTeamShortName(series.Team1) } + + } + { fmt.Sprint(series.Team1Wins) } +
    +
    + โ€“ + if isCompleted { + + FINAL + + } +
    +
    + { fmt.Sprint(series.Team2Wins) } + if series.Team2 != nil && series.Team2.Color != "" { + + { seriesTeamShortName(series.Team2) } + + } else { + + { seriesTeamShortName(series.Team2) } + + } + if team2Won { + 🏆 + } +
    +
    + } +
    +
    +} + +templ seriesScheduleSummary(series *db.PlayoffSeries, schedule *db.PlayoffSeriesSchedule) { + {{ + isCompleted := series.Status == db.SeriesStatusCompleted + }} +
    +
    +

    Schedule

    +
    +
    + if schedule == nil { +
    +

    No time scheduled

    +
    + } else if schedule.Status == db.ScheduleStatusAccepted && schedule.ScheduledTime != nil { +
    + if isCompleted { + + Played + + } else { + + Confirmed + + } +

    + @localtime(schedule.ScheduledTime, "date") +

    +

    + @localtime(schedule.ScheduledTime, "time") +

    +
    + } else if schedule.Status == db.ScheduleStatusPending && schedule.ScheduledTime != nil { +
    + + Proposed + +

    + @localtime(schedule.ScheduledTime, "date") +

    +

    + @localtime(schedule.ScheduledTime, "time") +

    +

    Awaiting confirmation

    +
    + } else if schedule.Status == db.ScheduleStatusCancelled { +
    + + Cancelled + + if schedule.RescheduleReason != nil { +

    { *schedule.RescheduleReason }

    + } +
    + } else { +
    +

    No time confirmed

    +
    + } +
    +
    +} + +templ seriesMatchList(series *db.PlayoffSeries) { +
    +
    +

    Matches

    +
    +
    + for _, match := range series.Matches { + @seriesMatchRow(series, match) + } +
    +
    +} + +templ seriesMatchRow(series *db.PlayoffSeries, match *db.PlayoffMatch) { + {{ + matchLabel := fmt.Sprintf("Game %d", match.MatchNumber) + isPending := match.Status == "pending" + isCompleted := match.Status == "completed" + hasFixture := match.FixtureID != nil + _ = hasFixture + }} +
    +
    + { matchLabel } + if isPending { + + Pending + + } else if isCompleted { + + Complete + + } else { + + { match.Status } + + } +
    + if match.FixtureID != nil { + + View Details + + } +
    +} + +templ seriesContextCard(series *db.PlayoffSeries) { + {{ + // Determine advancement info + winnerAdvances := "" + loserAdvances := "" + + if series.WinnerNextID != nil { + // Look through bracket series for the target + if series.Bracket != nil { + for _, s := range series.Bracket.Series { + if s.ID == *series.WinnerNextID { + winnerAdvances = s.Label + break + } + } + } + if winnerAdvances == "" { + winnerAdvances = "next round" + } + } + if series.LoserNextID != nil { + if series.Bracket != nil { + for _, s := range series.Bracket.Series { + if s.ID == *series.LoserNextID { + loserAdvances = s.Label + break + } + } + } + if loserAdvances == "" { + loserAdvances = "next round" + } + } + }} +
    +
    +

    Series Info

    +
    +
    +
    + Round + { roundDisplayName(series.Round) } +
    +
    + Format + Best of { fmt.Sprint(series.MatchesToWin*2 - 1) } (first to { fmt.Sprint(series.MatchesToWin) }) +
    + if series.Team1Seed != nil && series.Team2Seed != nil { +
    + Seeding + + { ordinal(*series.Team1Seed) } seed vs { ordinal(*series.Team2Seed) } seed + +
    + } + if winnerAdvances != "" { +
    + Winner โ†’ + { winnerAdvances } +
    + } else { +
    + Winner โ†’ + Champion +
    + } + if loserAdvances != "" { +
    + Loser โ†’ + { loserAdvances } +
    + } else if series.WinnerNextID != nil { +
    + Loser โ†’ + Eliminated +
    + } +
    +
    +} + +templ seriesTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, season *db.Season, league *db.League) { + {{ + // Sort with managers first + sort.SliceStable(players, func(i, j int) bool { + return players[i].IsManager && !players[j].IsManager + }) + }} +
    +
    +

    + @links.TeamNameLinkInSeason(team, season, league) +

    + if team.Color != "" { + + } +
    + if len(players) == 0 { +
    +

    No players on roster.

    +
    + } else { +
    +
    + for _, p := range players { +
    + + @links.PlayerLink(p.Player) + + if p.IsManager { + + ★ Manager + + } + if p.IsFreeAgent { + + FREE AGENT + + } +
    + } +
    +
    + } +
    +} diff --git a/internal/view/seasonsview/series_match_analysis.templ b/internal/view/seasonsview/series_match_analysis.templ new file mode 100644 index 0000000..799d93a --- /dev/null +++ b/internal/view/seasonsview/series_match_analysis.templ @@ -0,0 +1,251 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" + +// seriesMatchAnalysisTab renders the full Match Analysis tab for completed series. +// Shows final series score, individual match results, aggregated team stats, +// top performers, and league context. +templ seriesMatchAnalysisTab( + series *db.PlayoffSeries, + rosters map[string][]*db.PlayerWithPlayStatus, + preview *db.MatchPreviewData, +) { +
    + + @seriesAnalysisScoreHeader(series) + + + if len(series.Matches) > 0 { + @seriesAnalysisMatchResults(series) + } + + + if preview != nil { + @seriesAnalysisLeagueContext(series, preview) + } +
    +} + +// seriesAnalysisScoreHeader renders the final series score in a prominent display. +templ seriesAnalysisScoreHeader(series *db.PlayoffSeries) { + {{ + team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID + team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID + }} +
    +
    +

    Final Series Score

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

    + if series.Team1 != nil { + @links.TeamNameLinkInSeason(series.Team1, series.Bracket.Season, series.Bracket.League) + } else { + TBD + } +

    + + { fmt.Sprint(series.Team1Wins) } + + if team1Won { + Winner + } +
    + +
    + โ€“ +
    + +
    + if series.Team2 != nil && series.Team2.Color != "" { +
    + } +

    + if series.Team2 != nil { + @links.TeamNameLinkInSeason(series.Team2, series.Bracket.Season, series.Bracket.League) + } else { + TBD + } +

    + + { fmt.Sprint(series.Team2Wins) } + + if team2Won { + Winner + } +
    +
    +
    +
    +} + +// seriesAnalysisMatchResults shows individual match results as a compact list. +templ seriesAnalysisMatchResults(series *db.PlayoffSeries) { +
    +
    +

    Match Results

    +
    +
    + for _, match := range series.Matches { + @seriesAnalysisMatchRow(series, match) + } +
    +
    +} + +templ seriesAnalysisMatchRow(series *db.PlayoffSeries, match *db.PlayoffMatch) { + {{ + matchLabel := fmt.Sprintf("Game %d", match.MatchNumber) + isCompleted := match.Status == "completed" + }} +
    +
    + { matchLabel } + if isCompleted { + + Complete + + } else { + + { match.Status } + + } +
    + if match.FixtureID != nil { + + View Details + + } +
    +} + +// seriesAnalysisLeagueContext shows how the teams sit in the league standings. +templ seriesAnalysisLeagueContext(series *db.PlayoffSeries, preview *db.MatchPreviewData) { +
    +
    +

    League Context

    +
    +
    + +
    +
    +
    + if series.Team1 != nil && series.Team1.Color != "" { + + } + { seriesTeamShortName(series.Team1) } +
    +
    +
    +
    +
    + { seriesTeamShortName(series.Team2) } + if series.Team2 != nil && series.Team2.Color != "" { + + } +
    +
    +
    +
    + {{ + homePos := ordinal(preview.HomePosition) + awayPos := ordinal(preview.AwayPosition) + if preview.HomePosition == 0 { + homePos = "N/A" + } + if preview.AwayPosition == 0 { + awayPos = "N/A" + } + }} + @previewStatRow( + homePos, + "Position", + awayPos, + preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition, + preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Points), + "Points", + fmt.Sprint(preview.AwayRecord.Points), + preview.HomeRecord.Points > preview.AwayRecord.Points, + preview.AwayRecord.Points > preview.HomeRecord.Points, + ) + @previewStatRow( + fmt.Sprintf("%d-%d-%d-%d", + preview.HomeRecord.Wins, + preview.HomeRecord.OvertimeWins, + preview.HomeRecord.OvertimeLosses, + preview.HomeRecord.Losses, + ), + "Record", + fmt.Sprintf("%d-%d-%d-%d", + preview.AwayRecord.Wins, + preview.AwayRecord.OvertimeWins, + preview.AwayRecord.OvertimeLosses, + preview.AwayRecord.Losses, + ), + false, + false, + ) + {{ + homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst + awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst + }} + @previewStatRow( + fmt.Sprintf("%+d", homeDiff), + "Goal Diff", + fmt.Sprintf("%+d", awayDiff), + homeDiff > awayDiff, + awayDiff > homeDiff, + ) + + if len(preview.HomeRecentGames) > 0 || len(preview.AwayRecentGames) > 0 { +
    +
    +
    + for _, g := range preview.HomeRecentGames { + @gameOutcomeIcon(g) + } +
    +
    +
    + Form +
    +
    +
    + for _, g := range preview.AwayRecentGames { + @gameOutcomeIcon(g) + } +
    +
    +
    + } +
    +
    +
    +} diff --git a/internal/view/seasonsview/series_match_preview.templ b/internal/view/seasonsview/series_match_preview.templ new file mode 100644 index 0000000..052151b --- /dev/null +++ b/internal/view/seasonsview/series_match_preview.templ @@ -0,0 +1,319 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" +import "sort" + +// seriesMatchPreviewTab renders the full Match Preview tab for upcoming series. +// Shows team standings comparison, recent form, and full rosters side-by-side. +templ seriesMatchPreviewTab( + series *db.PlayoffSeries, + rosters map[string][]*db.PlayerWithPlayStatus, + preview *db.MatchPreviewData, +) { +
    + + if preview != nil { + @seriesPreviewHeader(series, preview) + } + + + if preview != nil { + @seriesPreviewFormGuide(series, preview) + } + + + @seriesPreviewRosters(series, rosters) +
    +} + +// seriesPreviewHeader renders the broadcast-style team comparison with standings. +templ seriesPreviewHeader(series *db.PlayoffSeries, preview *db.MatchPreviewData) { +
    +
    +

    Team Comparison

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

    + if series.Team1 != nil { + @links.TeamNameLinkInSeason(series.Team1, series.Bracket.Season, series.Bracket.League) + } else { + TBD + } +

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

    + if series.Team2 != nil { + @links.TeamNameLinkInSeason(series.Team2, series.Bracket.Season, series.Bracket.League) + } else { + TBD + } +

    + if series.Team2 != nil { + { series.Team2.ShortName } + } +
    +
    + + {{ + homePos := ordinal(preview.HomePosition) + awayPos := ordinal(preview.AwayPosition) + if preview.HomePosition == 0 { + homePos = "N/A" + } + if preview.AwayPosition == 0 { + awayPos = "N/A" + } + }} +
    + @previewStatRow( + homePos, + "Position", + awayPos, + preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition, + preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Points), + "Points", + fmt.Sprint(preview.AwayRecord.Points), + preview.HomeRecord.Points > preview.AwayRecord.Points, + preview.AwayRecord.Points > preview.HomeRecord.Points, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Played), + "Played", + fmt.Sprint(preview.AwayRecord.Played), + false, + false, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Wins), + "Wins", + fmt.Sprint(preview.AwayRecord.Wins), + preview.HomeRecord.Wins > preview.AwayRecord.Wins, + preview.AwayRecord.Wins > preview.HomeRecord.Wins, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.OvertimeWins), + "OT Wins", + fmt.Sprint(preview.AwayRecord.OvertimeWins), + preview.HomeRecord.OvertimeWins > preview.AwayRecord.OvertimeWins, + preview.AwayRecord.OvertimeWins > preview.HomeRecord.OvertimeWins, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.OvertimeLosses), + "OT Losses", + fmt.Sprint(preview.AwayRecord.OvertimeLosses), + preview.HomeRecord.OvertimeLosses < preview.AwayRecord.OvertimeLosses, + preview.AwayRecord.OvertimeLosses < preview.HomeRecord.OvertimeLosses, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Losses), + "Losses", + fmt.Sprint(preview.AwayRecord.Losses), + preview.HomeRecord.Losses < preview.AwayRecord.Losses, + preview.AwayRecord.Losses < preview.HomeRecord.Losses, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.GoalsFor), + "Goals For", + fmt.Sprint(preview.AwayRecord.GoalsFor), + preview.HomeRecord.GoalsFor > preview.AwayRecord.GoalsFor, + preview.AwayRecord.GoalsFor > preview.HomeRecord.GoalsFor, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.GoalsAgainst), + "Goals Against", + fmt.Sprint(preview.AwayRecord.GoalsAgainst), + preview.HomeRecord.GoalsAgainst < preview.AwayRecord.GoalsAgainst, + preview.AwayRecord.GoalsAgainst < preview.HomeRecord.GoalsAgainst, + ) + {{ + homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst + awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst + }} + @previewStatRow( + fmt.Sprintf("%+d", homeDiff), + "Goal Diff", + fmt.Sprintf("%+d", awayDiff), + homeDiff > awayDiff, + awayDiff > homeDiff, + ) +
    +
    +
    +} + +// seriesPreviewFormGuide renders recent form for each team. +templ seriesPreviewFormGuide(series *db.PlayoffSeries, preview *db.MatchPreviewData) { +
    +
    +

    Recent Form

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

    { seriesTeamName(series.Team1) }

    +
    + if len(preview.HomeRecentGames) == 0 { +

    No recent matches played

    + } else { +
    + for _, g := range preview.HomeRecentGames { + @gameOutcomeIcon(g) + } +
    +
    + for i := len(preview.HomeRecentGames) - 1; i >= 0; i-- { + @recentGameRow(preview.HomeRecentGames[i]) + } +
    + } +
    + +
    +
    + if series.Team2 != nil && series.Team2.Color != "" { + + } +

    { seriesTeamName(series.Team2) }

    +
    + if len(preview.AwayRecentGames) == 0 { +

    No recent matches played

    + } else { +
    + for _, g := range preview.AwayRecentGames { + @gameOutcomeIcon(g) + } +
    +
    + for i := len(preview.AwayRecentGames) - 1; i >= 0; i-- { + @recentGameRow(preview.AwayRecentGames[i]) + } +
    + } +
    +
    +
    +
    +} + +// seriesPreviewRosters renders team rosters side-by-side. +templ seriesPreviewRosters(series *db.PlayoffSeries, rosters map[string][]*db.PlayerWithPlayStatus) { +
    +
    +

    Team Rosters

    +
    +
    +
    + if series.Team1 != nil { + @seriesPreviewRosterColumn(series.Team1, rosters["team1"], series.Bracket.Season, series.Bracket.League) + } + if series.Team2 != nil { + @seriesPreviewRosterColumn(series.Team2, rosters["team2"], series.Bracket.Season, series.Bracket.League) + } +
    +
    +
    +} + +templ seriesPreviewRosterColumn( + team *db.Team, + players []*db.PlayerWithPlayStatus, + season *db.Season, + league *db.League, +) { + {{ + var managers []*db.PlayerWithPlayStatus + var roster []*db.PlayerWithPlayStatus + for _, p := range players { + if p.IsManager { + managers = append(managers, p) + } else { + roster = append(roster, p) + } + } + sort.Slice(roster, func(i, j int) bool { + return roster[i].Player.DisplayName() < roster[j].Player.DisplayName() + }) + }} +
    +
    +
    + if team.Color != "" { + + } +

    + @links.TeamNameLinkInSeason(team, season, league) +

    +
    + + { fmt.Sprint(len(players)) } players + +
    + if len(players) == 0 { +

    No players on roster.

    + } else { +
    + for _, p := range managers { +
    + + ★ + + + @links.PlayerLink(p.Player) + +
    + } + for _, p := range roster { +
    + + @links.PlayerLink(p.Player) + +
    + } +
    + } +
    +} diff --git a/internal/view/seasonsview/series_schedule.templ b/internal/view/seasonsview/series_schedule.templ new file mode 100644 index 0000000..724451c --- /dev/null +++ b/internal/view/seasonsview/series_schedule.templ @@ -0,0 +1,504 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" + +// ==================== Schedule Tab ==================== +templ seriesScheduleTab( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + history []*db.PlayoffSeriesSchedule, + canSchedule bool, + canManage bool, + userTeamID int, +) { +
    + @seriesScheduleStatus(series, currentSchedule, canSchedule, canManage, userTeamID) + @seriesScheduleActions(series, currentSchedule, canSchedule, canManage, userTeamID) + @seriesScheduleHistory(series, history) +
    +} + +templ seriesScheduleStatus( + series *db.PlayoffSeries, + current *db.PlayoffSeriesSchedule, + canSchedule bool, + canManage bool, + userTeamID int, +) { + {{ + bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil + }} +
    +
    +

    Schedule Status

    +
    +
    + if !bothTeamsAssigned { +
    +
    โณ
    +

    Waiting for Teams

    +

    + Both teams must be determined before scheduling can begin. +

    +
    + } else if current == nil { +
    +
    ๐Ÿ“…
    +

    No time scheduled

    +

    + if canSchedule { + Use the form to propose a time for this series. + } else { + A team manager needs to propose a time for this series. + } +

    +
    + } else if current.Status == db.ScheduleStatusPending && current.ScheduledTime != nil { +
    +
    โณ
    +

    + Proposed: + @localtime(current.ScheduledTime, "datetime") +

    +

    + Proposed by + { current.ProposedBy.Name } + โ€” awaiting response from the other team +

    + if canSchedule && userTeamID != current.ProposedByTeamID { +
    +
    + +
    +
    + +
    +
    + } + if canSchedule && userTeamID == current.ProposedByTeamID { +
    + +
    + } +
    + } else if current.Status == db.ScheduleStatusAccepted { +
    +
    โœ…
    +

    + Confirmed: + @localtime(current.ScheduledTime, "datetime") +

    +

    + Both teams have agreed on this time. +

    +
    + } else if current.Status == db.ScheduleStatusRejected { +
    +
    โŒ
    +

    Proposal Rejected

    +

    + The proposed time was rejected. A new time needs to be proposed. +

    +
    + } else if current.Status == db.ScheduleStatusCancelled { +
    +
    ๐Ÿšซ
    +

    Schedule Cancelled

    + if current.RescheduleReason != nil { +

    + { *current.RescheduleReason } +

    + } +
    + } else if current.Status == db.ScheduleStatusRescheduled { +
    +
    ๐Ÿ”„
    +

    Rescheduled

    + if current.RescheduleReason != nil { +

    + Reason: { *current.RescheduleReason } +

    + } +

    + A new time needs to be proposed. +

    +
    + } else if current.Status == db.ScheduleStatusPostponed { +
    +
    โธ๏ธ
    +

    Postponed

    + if current.RescheduleReason != nil { +

    + Reason: { *current.RescheduleReason } +

    + } +

    + A new time needs to be proposed. +

    +
    + } else if current.Status == db.ScheduleStatusWithdrawn { +
    +
    โ†ฉ๏ธ
    +

    Proposal Withdrawn

    +

    + The proposed time was withdrawn. A new time needs to be proposed. +

    +
    + } +
    +
    +} + +templ seriesScheduleActions( + series *db.PlayoffSeries, + current *db.PlayoffSeriesSchedule, + canSchedule bool, + canManage bool, + userTeamID int, +) { + {{ + bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil + + showPropose := false + showReschedule := false + showPostpone := false + showCancel := false + + if bothTeamsAssigned && canSchedule { + if current == nil { + showPropose = true + } else if current.Status == db.ScheduleStatusRejected { + showPropose = true + } else if current.Status == db.ScheduleStatusRescheduled { + showPropose = true + } else if current.Status == db.ScheduleStatusPostponed { + showPropose = true + } else if current.Status == db.ScheduleStatusWithdrawn { + showPropose = true + } else if current.Status == db.ScheduleStatusAccepted { + showReschedule = true + showPostpone = true + } + } + if bothTeamsAssigned && canManage && current != nil && !current.Status.IsTerminal() { + showCancel = true + } + }} + if showPropose || showReschedule || showPostpone || showCancel { +
    + + if showPropose { +
    +
    +

    Propose Time

    +
    +
    +
    +
    + + +
    + +
    +
    +
    + } + + if showReschedule { +
    +
    +

    Reschedule

    +
    +
    +
    +
    + + +
    +
    + + @seriesRescheduleReasonSelect(series) +
    + +
    +
    +
    + } + + if showPostpone { +
    +
    +

    Postpone

    +
    +
    +
    +
    + + @seriesRescheduleReasonSelect(series) +
    + +
    +
    +
    + } + + if showCancel { +
    +
    +

    Cancel Schedule

    +
    +
    +

    + This action will cancel the current series schedule. +

    +
    +
    + + +
    + +
    +
    +
    + } +
    + } else { + if !canSchedule && !canManage { +
    +

    + Only team managers can manage series scheduling. +

    +
    + } + } +} + +templ seriesRescheduleReasonSelect(series *db.PlayoffSeries) { + {{ + team1Name := seriesTeamName(series.Team1) + team2Name := seriesTeamName(series.Team2) + }} + +} + +templ seriesScheduleHistory(series *db.PlayoffSeries, history []*db.PlayoffSeriesSchedule) { +
    +
    +

    Schedule History

    +
    +
    + if len(history) == 0 { +

    No scheduling activity yet.

    + } else { +
    + for i := len(history) - 1; i >= 0; i-- { + @seriesScheduleHistoryItem(history[i], i == len(history)-1) + } +
    + } +
    +
    +} + +templ seriesScheduleHistoryItem(schedule *db.PlayoffSeriesSchedule, isCurrent bool) { + {{ + statusColor := "text-subtext0" + statusBg := "bg-surface1" + statusLabel := string(schedule.Status) + switch schedule.Status { + case db.ScheduleStatusPending: + statusColor = "text-blue" + statusBg = "bg-blue/20" + statusLabel = "Pending" + case db.ScheduleStatusAccepted: + statusColor = "text-green" + statusBg = "bg-green/20" + statusLabel = "Accepted" + case db.ScheduleStatusRejected: + statusColor = "text-red" + statusBg = "bg-red/20" + statusLabel = "Rejected" + case db.ScheduleStatusRescheduled: + statusColor = "text-yellow" + statusBg = "bg-yellow/20" + statusLabel = "Rescheduled" + case db.ScheduleStatusPostponed: + statusColor = "text-peach" + statusBg = "bg-peach/20" + statusLabel = "Postponed" + case db.ScheduleStatusCancelled: + statusColor = "text-red" + statusBg = "bg-red/20" + statusLabel = "Cancelled" + case db.ScheduleStatusWithdrawn: + statusColor = "text-subtext0" + statusBg = "bg-surface1" + statusLabel = "Withdrawn" + } + }} +
    +
    +
    + if isCurrent { + + CURRENT + + } + + { statusLabel } + +
    + + @localtimeUnix(schedule.CreatedAt, "histdate") + +
    +
    +
    + Proposed by: + { schedule.ProposedBy.Name } +
    + if schedule.ScheduledTime != nil { +
    + Time: + + @localtime(schedule.ScheduledTime, "datetime") + +
    + } else { +
    + Time: + No time set +
    + } + if schedule.RescheduleReason != nil { +
    + Reason: + { *schedule.RescheduleReason } +
    + } +
    +
    +} From ad93c44fae5264726602a7bd80204c28d36a447f Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 15 Mar 2026 12:59:34 +1100 Subject: [PATCH 29/33] added finals log uploads --- internal/db/playoff_results.go | 355 +++++++++++++ internal/embedfs/web/css/output.css | 27 + internal/handlers/series_result.go | 502 ++++++++++++++++++ internal/server/routes.go | 26 + internal/view/seasonsview/series_detail.templ | 53 +- .../seasonsview/series_review_result.templ | 374 +++++++++++++ .../seasonsview/series_upload_result.templ | 133 +++++ 7 files changed, 1468 insertions(+), 2 deletions(-) create mode 100644 internal/db/playoff_results.go create mode 100644 internal/handlers/series_result.go create mode 100644 internal/view/seasonsview/series_review_result.templ create mode 100644 internal/view/seasonsview/series_upload_result.templ 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++ { +
    + + +
    + } +
    +
    + } + +
    + +
    +
    +
    +
    +
    + } +} From 9e729d20b34c5bcf3c5f326f60ee9121d4566f52 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 15 Mar 2026 13:11:26 +1100 Subject: [PATCH 30/33] finals stats moved out of regular season stats --- internal/db/fixture_result.go | 4 + internal/db/playoff_stats.go | 256 ++++++++++++++++++ internal/handlers/season_league_finals.go | 30 +- .../seasonsview/season_league_finals.templ | 66 ++++- 4 files changed, 350 insertions(+), 6 deletions(-) create mode 100644 internal/db/playoff_stats.go diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go index 5a14eeb..4cfa426 100644 --- a/internal/db/fixture_result.go +++ b/internal/db/fixture_result.go @@ -513,6 +513,7 @@ func GetAllLeaguePlayerStats( WHERE fr.finalized = true AND f.season_id = ? AND f.league_id = ? + AND f.round > 0 AND frps.period_num = 3 AND frps.player_id IS NOT NULL GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) @@ -583,6 +584,7 @@ func GetTopGoalScorers( WHERE fr.finalized = true AND f.season_id = ? AND f.league_id = ? + AND f.round > 0 AND frps.period_num = 3 AND frps.player_id IS NOT NULL GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) @@ -655,6 +657,7 @@ func GetTopAssisters( WHERE fr.finalized = true AND f.season_id = ? AND f.league_id = ? + AND f.round > 0 AND frps.period_num = 3 AND frps.player_id IS NOT NULL GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) @@ -727,6 +730,7 @@ func GetTopSavers( WHERE fr.finalized = true AND f.season_id = ? AND f.league_id = ? + AND f.round > 0 AND frps.period_num = 3 AND frps.player_id IS NOT NULL GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) diff --git a/internal/db/playoff_stats.go b/internal/db/playoff_stats.go new file mode 100644 index 0000000..51a1b22 --- /dev/null +++ b/internal/db/playoff_stats.go @@ -0,0 +1,256 @@ +package db + +import ( + "context" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// GetPlayoffPlayerStats returns aggregated player stats from playoff fixtures only +// (fixtures with round < 0) for a season-league. +// Reuses the same LeaguePlayerStats struct as regular season stats. +func GetPlayoffPlayerStats( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, +) ([]*LeaguePlayerStats, error) { + if seasonID == 0 { + return nil, errors.New("seasonID not provided") + } + if leagueID == 0 { + return nil, errors.New("leagueID not provided") + } + + var stats []*LeaguePlayerStats + err := tx.NewRaw(` + SELECT + agg.player_id, + agg.player_name, + COALESCE(tr.team_id, 0) AS team_id, + COALESCE(t.name, '') AS team_name, + COALESCE(t.color, '') AS team_color, + agg.games_played, + agg.total_periods_played, + agg.total_goals, + agg.total_assists, + agg.total_primary_assists, + agg.total_secondary_assists, + agg.total_saves, + agg.total_shots, + agg.total_blocks, + agg.total_passes, + agg.total_score + FROM ( + SELECT + frps.player_id AS player_id, + COALESCE(p.name, frps.player_username) AS player_name, + COUNT(DISTINCT frps.fixture_result_id) AS games_played, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, + COALESCE(SUM(frps.goals), 0) AS total_goals, + COALESCE(SUM(frps.assists), 0) AS total_assists, + COALESCE(SUM(frps.primary_assists), 0) AS total_primary_assists, + COALESCE(SUM(frps.secondary_assists), 0) AS total_secondary_assists, + COALESCE(SUM(frps.saves), 0) AS total_saves, + COALESCE(SUM(frps.shots), 0) AS total_shots, + COALESCE(SUM(frps.blocks), 0) AS total_blocks, + COALESCE(SUM(frps.passes), 0) AS total_passes, + COALESCE(SUM(frps.score), 0) AS total_score + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + JOIN fixtures f ON f.id = fr.fixture_id + LEFT JOIN players p ON p.id = frps.player_id + WHERE fr.finalized = true + AND f.season_id = ? + AND f.league_id = ? + AND f.round < 0 + AND frps.period_num = 3 + AND frps.player_id IS NOT NULL + GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) + ) agg + LEFT JOIN team_rosters tr + ON tr.player_id = agg.player_id + AND tr.season_id = ? + AND tr.league_id = ? + LEFT JOIN teams t ON t.id = tr.team_id + ORDER BY agg.total_score DESC + `, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + +// GetPlayoffTopGoalScorers returns the top 10 goal scorers from playoff fixtures. +func GetPlayoffTopGoalScorers( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, +) ([]*LeagueTopGoalScorer, error) { + if seasonID == 0 { + return nil, errors.New("seasonID not provided") + } + if leagueID == 0 { + return nil, errors.New("leagueID not provided") + } + + var stats []*LeagueTopGoalScorer + err := tx.NewRaw(` + SELECT + agg.player_id, + agg.player_name, + COALESCE(tr.team_id, 0) AS team_id, + COALESCE(t.name, '') AS team_name, + COALESCE(t.color, '') AS team_color, + agg.total_goals, + agg.total_periods_played, + agg.total_shots + FROM ( + SELECT + frps.player_id AS player_id, + COALESCE(p.name, frps.player_username) AS player_name, + COALESCE(SUM(frps.goals), 0) AS total_goals, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, + COALESCE(SUM(frps.shots), 0) AS total_shots + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + JOIN fixtures f ON f.id = fr.fixture_id + LEFT JOIN players p ON p.id = frps.player_id + WHERE fr.finalized = true + AND f.season_id = ? + AND f.league_id = ? + AND f.round < 0 + AND frps.period_num = 3 + AND frps.player_id IS NOT NULL + GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) + ORDER BY total_goals DESC, total_periods_played ASC, total_shots ASC + LIMIT 10 + ) agg + LEFT JOIN team_rosters tr + ON tr.player_id = agg.player_id + AND tr.season_id = ? + AND tr.league_id = ? + LEFT JOIN teams t ON t.id = tr.team_id + ORDER BY agg.total_goals DESC, agg.total_periods_played ASC, agg.total_shots ASC + `, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + +// GetPlayoffTopAssisters returns the top 10 assisters from playoff fixtures. +func GetPlayoffTopAssisters( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, +) ([]*LeagueTopAssister, error) { + if seasonID == 0 { + return nil, errors.New("seasonID not provided") + } + if leagueID == 0 { + return nil, errors.New("leagueID not provided") + } + + var stats []*LeagueTopAssister + err := tx.NewRaw(` + SELECT + agg.player_id, + agg.player_name, + COALESCE(tr.team_id, 0) AS team_id, + COALESCE(t.name, '') AS team_name, + COALESCE(t.color, '') AS team_color, + agg.total_assists, + agg.total_periods_played, + agg.total_primary_assists + FROM ( + SELECT + frps.player_id AS player_id, + COALESCE(p.name, frps.player_username) AS player_name, + COALESCE(SUM(frps.assists), 0) AS total_assists, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, + COALESCE(SUM(frps.primary_assists), 0) AS total_primary_assists + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + JOIN fixtures f ON f.id = fr.fixture_id + LEFT JOIN players p ON p.id = frps.player_id + WHERE fr.finalized = true + AND f.season_id = ? + AND f.league_id = ? + AND f.round < 0 + AND frps.period_num = 3 + AND frps.player_id IS NOT NULL + GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) + ORDER BY total_assists DESC, total_periods_played ASC, total_primary_assists DESC + LIMIT 10 + ) agg + LEFT JOIN team_rosters tr + ON tr.player_id = agg.player_id + AND tr.season_id = ? + AND tr.league_id = ? + LEFT JOIN teams t ON t.id = tr.team_id + ORDER BY agg.total_assists DESC, agg.total_periods_played ASC, agg.total_primary_assists DESC + `, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} + +// GetPlayoffTopSavers returns the top 10 savers from playoff fixtures. +func GetPlayoffTopSavers( + ctx context.Context, + tx bun.Tx, + seasonID, leagueID int, +) ([]*LeagueTopSaver, error) { + if seasonID == 0 { + return nil, errors.New("seasonID not provided") + } + if leagueID == 0 { + return nil, errors.New("leagueID not provided") + } + + var stats []*LeagueTopSaver + err := tx.NewRaw(` + SELECT + agg.player_id, + agg.player_name, + COALESCE(tr.team_id, 0) AS team_id, + COALESCE(t.name, '') AS team_name, + COALESCE(t.color, '') AS team_color, + agg.total_saves, + agg.total_periods_played, + agg.total_blocks + FROM ( + SELECT + frps.player_id AS player_id, + COALESCE(p.name, frps.player_username) AS player_name, + COALESCE(SUM(frps.saves), 0) AS total_saves, + COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, + COALESCE(SUM(frps.blocks), 0) AS total_blocks + FROM fixture_result_player_stats frps + JOIN fixture_results fr ON fr.id = frps.fixture_result_id + JOIN fixtures f ON f.id = fr.fixture_id + LEFT JOIN players p ON p.id = frps.player_id + WHERE fr.finalized = true + AND f.season_id = ? + AND f.league_id = ? + AND f.round < 0 + AND frps.period_num = 3 + AND frps.player_id IS NOT NULL + GROUP BY frps.player_id, COALESCE(p.name, frps.player_username) + ORDER BY total_saves DESC, total_periods_played ASC, total_blocks DESC + LIMIT 10 + ) agg + LEFT JOIN team_rosters tr + ON tr.player_id = agg.player_id + AND tr.season_id = ? + AND tr.league_id = ? + LEFT JOIN teams t ON t.id = tr.team_id + ORDER BY agg.total_saves DESC, agg.total_periods_played ASC, agg.total_blocks DESC + `, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats) + if err != nil { + return nil, errors.Wrap(err, "tx.NewRaw") + } + return stats, nil +} diff --git a/internal/handlers/season_league_finals.go b/internal/handlers/season_league_finals.go index 5288d1e..e464d09 100644 --- a/internal/handlers/season_league_finals.go +++ b/internal/handlers/season_league_finals.go @@ -22,7 +22,7 @@ import ( // 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 Soon/Finals/Completed: The playoff bracket + finals stats func SeasonLeagueFinalsPage( s *hws.Server, conn *db.DB, @@ -34,6 +34,10 @@ func SeasonLeagueFinalsPage( 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 @@ -54,15 +58,35 @@ func SeasonLeagueFinalsPage( 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), s, r, w) + renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league, bracket, topGoals, topAssists, topSaves, allStats), s, r, w) } else { - renderSafely(seasonsview.SeasonLeagueFinals(season, league, bracket), s, r, w) + renderSafely(seasonsview.SeasonLeagueFinals(season, league, bracket, topGoals, topAssists, topSaves, allStats), s, r, w) } }) } diff --git a/internal/view/seasonsview/season_league_finals.templ b/internal/view/seasonsview/season_league_finals.templ index 3742fbe..db5b006 100644 --- a/internal/view/seasonsview/season_league_finals.templ +++ b/internal/view/seasonsview/season_league_finals.templ @@ -5,20 +5,43 @@ import "git.haelnorr.com/h/oslstats/internal/contexts" import "git.haelnorr.com/h/oslstats/internal/permissions" import "fmt" -templ SeasonLeagueFinalsPage(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { +templ SeasonLeagueFinalsPage( + season *db.Season, + league *db.League, + bracket *db.PlayoffBracket, + topGoals []*db.LeagueTopGoalScorer, + topAssists []*db.LeagueTopAssister, + topSaves []*db.LeagueTopSaver, + allStats []*db.LeaguePlayerStats, +) { @SeasonLeagueLayout("finals", season, league) { - @SeasonLeagueFinals(season, league, bracket) + @SeasonLeagueFinals(season, league, bracket, topGoals, topAssists, topSaves, allStats) } } -templ SeasonLeagueFinals(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { +templ SeasonLeagueFinals( + season *db.Season, + league *db.League, + bracket *db.PlayoffBracket, + topGoals []*db.LeagueTopGoalScorer, + topAssists []*db.LeagueTopAssister, + topSaves []*db.LeagueTopSaver, + allStats []*db.LeaguePlayerStats, +) { {{ permCache := contexts.Permissions(ctx) canManagePlayoffs := permCache != nil && permCache.HasPermission(permissions.PlayoffsManage) + hasStats := len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 || len(allStats) > 0 }}
    if bracket != nil { @PlayoffBracketView(season, league, bracket) + + if hasStats { +
    + @finalsStatsSection(season, league, topGoals, topAssists, topSaves, allStats) +
    + } } else if canManagePlayoffs { @finalsNotYetConfigured(season, league) } else { @@ -27,6 +50,43 @@ templ SeasonLeagueFinals(season *db.Season, league *db.League, bracket *db.Playo
    } +templ finalsStatsSection( + season *db.Season, + league *db.League, + topGoals []*db.LeagueTopGoalScorer, + topAssists []*db.LeagueTopAssister, + topSaves []*db.LeagueTopSaver, + allStats []*db.LeaguePlayerStats, +) { + +
    +
    + +

    Finals Stats

    +
    + + if len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 { +
    +

    Trophy Leaders

    +
    +
    + @topGoalScorersTable(season, league, topGoals) + @topAssistersTable(season, league, topAssists) +
    + @topSaversTable(season, league, topSaves) +
    +
    + } + + if len(allStats) > 0 { +
    +

    All Finals Stats

    + @allStatsTable(season, league, allStats) +
    + } +
    +} + templ finalsNotYetConfigured(season *db.Season, league *db.League) {
    From 2835ef74fcc6790abd69ffa6682b26dcbf36090e Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 15 Mar 2026 17:01:03 +1100 Subject: [PATCH 31/33] fixed invalid_grant resulting in internal server error --- internal/server/middleware.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/server/middleware.go b/internal/server/middleware.go index c75abcc..44d99e0 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -124,6 +124,9 @@ func refreshToken( case "expired", "expiring": newtoken, err := discordAPI.RefreshToken(token.Convert()) if err != nil { + if strings.Contains(err.Error(), "invalid_grant") { + return false, nil + } return false, errors.Wrap(err, "discordAPI.RefreshToken") } err = user.UpdateDiscordToken(ctx, tx, newtoken) From fd002a7ad0bb074a68f5d5bdd8f67cdab539c953 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 15 Mar 2026 17:02:19 +1100 Subject: [PATCH 32/33] fixed forfeit button not working on page load --- .../view/seasonsview/fixture_detail.templ | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/internal/view/seasonsview/fixture_detail.templ b/internal/view/seasonsview/fixture_detail.templ index df25898..ce73499 100644 --- a/internal/view/seasonsview/fixture_detail.templ +++ b/internal/view/seasonsview/fixture_detail.templ @@ -520,32 +520,6 @@ templ fixtureResultDisplay(fixture *db.Fixture, result *db.FixtureResult) { } templ fixtureUploadPrompt(fixture *db.Fixture) { -
    -
    ๐Ÿ“‹
    -

    No Result Uploaded

    -

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

    -
    - - Upload Match Logs - - -
    -
    - @forfeitModal(fixture) -} - -templ forfeitModal(fixture *db.Fixture) {
    +
    +
    ๐Ÿ“‹
    +

    No Result Uploaded

    +

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

    +
    + + Upload Match Logs + + +
    +
    + @forfeitModal(fixture) +
    +} + +templ forfeitModal(fixture *db.Fixture) { +
    Date: Sun, 15 Mar 2026 17:08:26 +1100 Subject: [PATCH 33/33] fixed some button issues --- internal/view/seasonsview/fixture_detail.templ | 6 +++--- .../view/seasonsview/fixture_review_result.templ | 16 ++++++++-------- internal/view/seasonsview/leagues_section.templ | 2 +- .../view/seasonsview/series_review_result.templ | 2 +- internal/view/seasonsview/series_schedule.templ | 6 +++--- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/view/seasonsview/fixture_detail.templ b/internal/view/seasonsview/fixture_detail.templ index ce73499..72e2603 100644 --- a/internal/view/seasonsview/fixture_detail.templ +++ b/internal/view/seasonsview/fixture_detail.templ @@ -1205,7 +1205,7 @@ templ fixtureScheduleStatus(
    - +
    diff --git a/internal/view/seasonsview/leagues_section.templ b/internal/view/seasonsview/leagues_section.templ index f15bc2f..7a16b8a 100644 --- a/internal/view/seasonsview/leagues_section.templ +++ b/internal/view/seasonsview/leagues_section.templ @@ -35,7 +35,7 @@ templ LeaguesSection(season *db.Season, allLeagues []*db.League) { if canRemoveLeague {