From 723a213be3765f724b79c4f39ad65b0bc2dbb207 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 8 Mar 2026 18:12:03 +1100 Subject: [PATCH 1/8] 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 2/8] 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 fc7f5665e7291b616a9e76cde3f328713be6d686 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 15 Mar 2026 12:05:47 +1100 Subject: [PATCH 3/8] 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 ea2f82ba9ee66b54096668e000350ebee2229327 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 15 Mar 2026 12:59:34 +1100 Subject: [PATCH 4/8] 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 f98b7b2d88093a2c9b36033f2741fbd418942ab2 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 15 Mar 2026 13:11:26 +1100 Subject: [PATCH 5/8] 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 957ca4aec65f11cd0e1f88b4fbd43decbaaecc3e Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 15 Mar 2026 17:01:03 +1100 Subject: [PATCH 6/8] 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 82a71d6490e088da264e0f3572e16fd80f1b739c Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 15 Mar 2026 17:02:19 +1100 Subject: [PATCH 7/8] 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 8/8] 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 {