From 0c5a88c309969e222dd18553b432a354376cb713 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 15 Feb 2026 19:56:03 +1100 Subject: [PATCH] we have fixtures ladies and gentleman --- internal/db/audit.go | 8 +- internal/db/errors.go | 14 +- internal/db/fixture.go | 282 +++++++++++ internal/db/getlist.go | 13 + internal/db/insert.go | 1 + internal/db/league.go | 12 +- internal/db/migrate/migrate.go | 2 +- .../20260213162216_missing_permissions.go | 4 +- .../migrations/20260215093841_add_fixtures.go | 52 ++ internal/db/season.go | 36 +- internal/db/seasonleague.go | 30 +- internal/db/team.go | 14 +- internal/db/teamparticipation.go | 10 +- internal/db/user.go | 10 +- internal/embedfs/web/css/output.css | 81 +++ internal/handlers/fixtures.go | 173 +++++++ internal/handlers/season_league_fixtures.go | 80 ++- internal/handlers/teams_new.go | 2 +- internal/permissions/constants.go | 5 + internal/server/routes.go | 29 ++ internal/validation/forms.go | 18 + .../seasonsview/season_league_fixtures.templ | 80 ++- .../season_league_fixtures_manage.templ | 477 ++++++++++++++++++ 23 files changed, 1355 insertions(+), 78 deletions(-) create mode 100644 internal/db/fixture.go create mode 100644 internal/db/migrations/20260215093841_add_fixtures.go create mode 100644 internal/handlers/fixtures.go create mode 100644 internal/view/seasonsview/season_league_fixtures_manage.templ diff --git a/internal/db/audit.go b/internal/db/audit.go index d7013e7..98b6817 100644 --- a/internal/db/audit.go +++ b/internal/db/audit.go @@ -20,10 +20,10 @@ func NewAudit(r *http.Request, u *User) *AuditMeta { // AuditInfo contains metadata for audit logging type AuditInfo struct { - Action string // e.g., "seasons.create", "users.update" - ResourceType string // e.g., "season", "user" - ResourceID any // Primary key value (int, string, etc.) - Details map[string]any // Changed fields or additional metadata + Action string // e.g., "seasons.create", "users.update" + ResourceType string // e.g., "season", "user" + ResourceID any // Primary key value (int, string, etc.) + Details any // Changed fields or additional metadata } // extractTableName gets the bun table name from a model type using reflection diff --git a/internal/db/errors.go b/internal/db/errors.go index d0b7db5..f85f44f 100644 --- a/internal/db/errors.go +++ b/internal/db/errors.go @@ -6,7 +6,7 @@ import ( ) func IsBadRequest(err error) bool { - return strings.HasPrefix(err.Error(), "bad request:") + return strings.Contains(err.Error(), "bad request:") } func BadRequest(err string) error { @@ -18,14 +18,14 @@ func BadRequestNotFound(resource, field string, value any) error { return BadRequest(errStr) } -func BadRequestNotAssociated(parent, child string, parentID, childID any) error { - errStr := fmt.Sprintf("%s (ID: %v) not associated with %s (ID: %v)", - child, childID, parent, parentID) +func BadRequestNotAssociated(parent, child, parentField, childField string, parentID, childID any) error { + errStr := fmt.Sprintf("%s with %s=%v not associated to %s with %s=%v", + child, childField, childID, parent, parentField, parentID) return BadRequest(errStr) } -func BadRequestAssociated(parent, child string, parentID, childID any) error { - errStr := fmt.Sprintf("%s (ID: %v) already associated with %s (ID: %v)", - child, childID, parent, parentID) +func BadRequestAssociated(parent, child, parentField, childField string, parentID, childID any) error { + errStr := fmt.Sprintf("%s with %s=%v already associated to %s with %s=%v", + child, childField, childID, parent, parentField, parentID) return BadRequest(errStr) } diff --git a/internal/db/fixture.go b/internal/db/fixture.go new file mode 100644 index 0000000..15b0f71 --- /dev/null +++ b/internal/db/fixture.go @@ -0,0 +1,282 @@ +package db + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type Fixture struct { + bun.BaseModel `bun:"table:fixtures,alias:f"` + + ID int `bun:"id,pk,autoincrement"` + SeasonID int `bun:",notnull,unique:round"` + LeagueID int `bun:",notnull,unique:round"` + HomeTeamID int `bun:",notnull,unique:round"` + AwayTeamID int `bun:",notnull,unique:round"` + Round int `bun:"round,unique:round"` + GameWeek *int `bun:"game_week"` + CreatedAt int64 `bun:"created_at,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"` + HomeTeam *Team `bun:"rel:belongs-to,join:home_team_id=id"` + AwayTeam *Team `bun:"rel:belongs-to,join:away_team_id=id"` +} + +func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, + homeTeamID, awayTeamID, round int, audit *AuditMeta, +) (*Fixture, error) { + season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) + if err != nil { + return nil, errors.Wrap(err, "GetSeasonLeague") + } + homeTeam, err := GetTeam(ctx, tx, homeTeamID) + if err != nil { + return nil, errors.Wrap(err, "GetTeam") + } + awayTeam, err := GetTeam(ctx, tx, awayTeamID) + if err != nil { + return nil, errors.Wrap(err, "GetTeam") + } + if err = checkTeamsAssociated(season, league, teams, []*Team{homeTeam, awayTeam}); err != nil { + return nil, errors.Wrap(err, "checkTeamsAssociated") + } + fixture := newFixture(season, league, homeTeam, awayTeam, round, time.Now()) + err = Insert(tx, fixture).WithAudit(audit, nil).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "Insert") + } + return fixture, nil +} + +func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, + round int, audit *AuditMeta, +) ([]*Fixture, error) { + season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) + if err != nil { + return nil, errors.Wrap(err, "GetSeasonLeague") + } + fixtures := generateRound(season, league, round, teams) + err = InsertMultiple(tx, fixtures).WithAudit(audit, nil).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "InsertMultiple") + } + return fixtures, nil +} + +func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Fixture, error) { + season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) + if err != nil { + return nil, nil, nil, errors.Wrap(err, "GetSeasonLeague") + } + fixtures, err := GetList[Fixture](tx). + Where("season_id = ?", season.ID). + Where("league_id = ?", league.ID). + Order("game_week ASC NULLS FIRST", "round ASC", "id ASC"). + Relation("HomeTeam"). + Relation("AwayTeam"). + GetAll(ctx) + if err != nil { + return nil, nil, nil, errors.Wrap(err, "GetList") + } + return season, league, fixtures, nil +} + +func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) { + return GetByID[Fixture](tx, id). + Relation("Season"). + Relation("League"). + Relation("HomeTeam"). + Relation("AwayTeam"). + Get(ctx) +} + +func GetFixturesByGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID, gameweek int) ([]*Fixture, error) { + fixtures, err := GetList[Fixture](tx). + Where("season_id = ?", seasonID). + Where("league_id = ?", leagueID). + Where("game_week = ?", gameweek). + Order("round ASC", "id ASC"). + Relation("HomeTeam"). + Relation("AwayTeam"). + GetAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "GetList") + } + return fixtures, nil +} + +func GetUnallocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Fixture, error) { + fixtures, err := GetList[Fixture](tx). + Where("season_id = ?", seasonID). + Where("league_id = ?", leagueID). + Where("game_week IS NULL"). + Order("round ASC", "id ASC"). + Relation("HomeTeam"). + Relation("AwayTeam"). + GetAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "GetList") + } + return fixtures, nil +} + +func CountUnallocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) (int, error) { + count, err := GetList[Fixture](tx). + Where("season_id = ?", seasonID). + Where("league_id = ?", leagueID). + Where("game_week IS NULL"). + Count(ctx) + if err != nil { + return 0, errors.Wrap(err, "GetList") + } + return count, nil +} + +func GetMaxGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID int) (int, error) { + var maxGameWeek int + err := tx.NewSelect(). + Model((*Fixture)(nil)). + Column("game_week"). + Where("season_id = ?", seasonID). + Where("league_id = ?", leagueID). + Order("game_week DESC NULLS LAST"). + Limit(1).Scan(ctx, &maxGameWeek) + if err != nil { + return 0, errors.Wrap(err, "tx.NewSelect") + } + return maxGameWeek, nil +} + +func UpdateFixtureGameWeeks(ctx context.Context, tx bun.Tx, fixtures []*Fixture, audit *AuditMeta) error { + details := []any{} + for _, fixture := range fixtures { + err := UpdateByID(tx, fixture.ID, fixture). + Column("game_week"). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + details = append(details, map[string]any{"fixture_id": fixture.ID, "game_week": fixture.GameWeek}) + } + info := &AuditInfo{ + "fixtures.manage", + "fixture", + "multiple", + map[string]any{"updated": details}, + } + err := LogSuccess(ctx, tx, audit, info) + if err != nil { + return errors.Wrap(err, "LogSuccess") + } + return nil +} + +func DeleteAllFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, audit *AuditMeta) error { + season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) + if err != nil { + return errors.Wrap(err, "GetSeasonLeague") + } + err = DeleteItem[Fixture](tx). + Where("season_id = ?", season.ID). + Where("league_id = ?", league.ID). + WithAudit(audit, nil). + Delete(ctx) + if err != nil { + return errors.Wrap(err, "DeleteItem") + } + return nil +} + +func DeleteFixture(ctx context.Context, tx bun.Tx, id int, audit *AuditMeta) error { + err := DeleteByID[Fixture](tx, id). + WithAudit(audit, nil). + Delete(ctx) + if err != nil { + return errors.Wrap(err, "DeleteByID") + } + return nil +} + +func newFixture(season *Season, league *League, homeTeam, awayTeam *Team, round int, created time.Time) *Fixture { + return &Fixture{ + SeasonID: season.ID, + LeagueID: league.ID, + HomeTeamID: homeTeam.ID, + AwayTeamID: awayTeam.ID, + Round: round, + CreatedAt: created.Unix(), + } +} + +func checkTeamsAssociated(season *Season, league *League, teamsIn []*Team, toCheck []*Team) error { + badIDs := []string{} + master := map[int]bool{} + for _, team := range teamsIn { + master[team.ID] = true + } + for _, team := range toCheck { + if !master[team.ID] { + badIDs = append(badIDs, strconv.Itoa(team.ID)) + } + } + ids := strings.Join(badIDs, ",") + if len(ids) > 0 { + return BadRequestNotAssociated("season_league", "team", + "season_id,league_id", "ids", + fmt.Sprintf("%v,%v", season.ID, league.ID), + ids) + } + return nil +} + +type versus struct { + homeTeam *Team + awayTeam *Team +} + +func generateRound(season *Season, league *League, round int, teams []*Team) []*Fixture { + now := time.Now() + numTeams := len(teams) + numGames := numTeams * (numTeams - 1) / 2 + fixtures := make([]*Fixture, numGames) + for i, matchup := range allTeamsPlay(teams, round) { + fixtures[i] = newFixture(season, league, matchup.homeTeam, matchup.awayTeam, round, now) + } + return fixtures +} + +func allTeamsPlay(teams []*Team, round int) []*versus { + matchups := []*versus{} + if len(teams) < 2 { + return matchups + } + team1 := teams[0] + teams = teams[1:] + matchups = append(matchups, playOtherTeams(team1, teams, round)...) + matchups = append(matchups, allTeamsPlay(teams, round)...) + return matchups +} + +func playOtherTeams(team *Team, teams []*Team, round int) []*versus { + matchups := make([]*versus, len(teams)) + for i, opponent := range teams { + versus := &versus{} + if i%2+round%2 == 0 { + versus.homeTeam = team + versus.awayTeam = opponent + } else { + versus.homeTeam = opponent + versus.awayTeam = team + } + matchups[i] = versus + } + return matchups +} diff --git a/internal/db/getlist.go b/internal/db/getlist.go index d1f887c..52033ae 100644 --- a/internal/db/getlist.go +++ b/internal/db/getlist.go @@ -92,6 +92,11 @@ func (l *listgetter[T]) Where(query string, args ...any) *listgetter[T] { return l } +func (l *listgetter[T]) Order(orders ...string) *listgetter[T] { + l.q = l.q.Order(orders...) + return l +} + func (l *listgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *listgetter[T] { l.q = l.q.Relation(name, apply...) return l @@ -130,6 +135,14 @@ func (l *listgetter[T]) GetPaged(ctx context.Context, pageOpts, defaults *PageOp return list, nil } +func (l *listgetter[T]) Count(ctx context.Context) (int, error) { + count, err := l.q.Count(ctx) + if err != nil { + return 0, errors.Wrap(err, "query.Count") + } + return count, nil +} + func (l *listgetter[T]) GetAll(ctx context.Context) ([]*T, error) { err := l.q.Scan(ctx) if err != nil && errors.Is(err, sql.ErrNoRows) { diff --git a/internal/db/insert.go b/internal/db/insert.go index 30b4c45..9b1a951 100644 --- a/internal/db/insert.go +++ b/internal/db/insert.go @@ -109,6 +109,7 @@ func (i *inserter[T]) Exec(ctx context.Context) error { } } else { i.auditInfo.ResourceID = extractPrimaryKey(i.model) + i.auditInfo.Details = i.model } } diff --git a/internal/db/league.go b/internal/db/league.go index 1814086..edef8d7 100644 --- a/internal/db/league.go +++ b/internal/db/league.go @@ -10,13 +10,13 @@ import ( type League struct { bun.BaseModel `bun:"table:leagues,alias:l"` - ID int `bun:"id,pk,autoincrement"` - Name string `bun:"name,unique,notnull"` - ShortName string `bun:"short_name,unique,notnull"` - Description string `bun:"description"` + ID int `bun:"id,pk,autoincrement" json:"id"` + Name string `bun:"name,unique,notnull" json:"name"` + ShortName string `bun:"short_name,unique,notnull" json:"short_name"` + Description string `bun:"description" json:"description"` - Seasons []Season `bun:"m2m:season_leagues,join:League=Season"` - Teams []Team `bun:"m2m:team_participations,join:League=Team"` + Seasons []Season `bun:"m2m:season_leagues,join:League=Season" json:"-"` + Teams []Team `bun:"m2m:team_participations,join:League=Team" json:"-"` } func GetLeagues(ctx context.Context, tx bun.Tx) ([]*League, error) { diff --git a/internal/db/migrate/migrate.go b/internal/db/migrate/migrate.go index 1fa523b..4393e3d 100644 --- a/internal/db/migrate/migrate.go +++ b/internal/db/migrate/migrate.go @@ -256,7 +256,7 @@ func migrateStatus(ctx context.Context, migrator *migrate.Migrator) error { // validateMigrations ensures migrations compile before running func validateMigrations(ctx context.Context) error { cmd := exec.CommandContext(ctx, "go", "build", - "-o", "/dev/null", "./cmd/oslstats/migrations") + "-o", "/dev/null", "./internal/db/migrations") output, err := cmd.CombinedOutput() if err != nil { diff --git a/internal/db/migrations/20260213162216_missing_permissions.go b/internal/db/migrations/20260213162216_missing_permissions.go index ccbd3c8..a6b035e 100644 --- a/internal/db/migrations/20260213162216_missing_permissions.go +++ b/internal/db/migrations/20260213162216_missing_permissions.go @@ -31,12 +31,12 @@ func init() { Model(&permissionsData). Exec(ctx) if err != nil { - return errors.Wrap(err, "dbConn.NewInsert") + return errors.Wrap(err, "conn.NewInsert") } return nil }, // DOWN migration - func(ctx context.Context, dbConn *bun.DB) error { + func(ctx context.Context, conn *bun.DB) error { // Add your rollback code here return nil }, diff --git a/internal/db/migrations/20260215093841_add_fixtures.go b/internal/db/migrations/20260215093841_add_fixtures.go new file mode 100644 index 0000000..52d0836 --- /dev/null +++ b/internal/db/migrations/20260215093841_add_fixtures.go @@ -0,0 +1,52 @@ +package migrations + +import ( + "context" + "time" + + "git.haelnorr.com/h/oslstats/internal/db" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister( + // UP migration + func(ctx context.Context, conn *bun.DB) error { + // Add your migration code here + _, err := conn.NewCreateTable(). + Model((*db.Fixture)(nil)). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + now := time.Now().Unix() + permissionsData := []*db.Permission{ + {Name: "fixtures.create", DisplayName: "Create Fixtures", Description: "Create new fixtures", Resource: "fixtures", Action: "create", IsSystem: true, CreatedAt: now}, + {Name: "fixtures.manage", DisplayName: "Manage Fixtures", Description: "Manage fixtures", Resource: "fixtures", Action: "manage", IsSystem: true, CreatedAt: now}, + {Name: "fixtures.delete", DisplayName: "Delete Fixtures", Description: "Delete fixtures", Resource: "fixtures", Action: "delete", IsSystem: true, CreatedAt: now}, + } + + _, err = conn.NewInsert(). + Model(&permissionsData). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "conn.NewInsert") + } + return nil + }, + // DOWN migration + func(ctx context.Context, conn *bun.DB) error { + // Add your rollback code here + _, err := conn.NewDropTable(). + Model((*db.Fixture)(nil)). + IfExists(). + Exec(ctx) + if err != nil { + return err + } + return nil + }, + ) +} diff --git a/internal/db/season.go b/internal/db/season.go index 7a2bf0e..ec3c572 100644 --- a/internal/db/season.go +++ b/internal/db/season.go @@ -28,17 +28,17 @@ const ( type Season struct { bun.BaseModel `bun:"table:seasons,alias:s"` - ID int `bun:"id,pk,autoincrement"` - Name string `bun:"name,unique,notnull"` - ShortName string `bun:"short_name,unique,notnull"` - StartDate time.Time `bun:"start_date,notnull"` - EndDate bun.NullTime `bun:"end_date"` - FinalsStartDate bun.NullTime `bun:"finals_start_date"` - FinalsEndDate bun.NullTime `bun:"finals_end_date"` - SlapVersion string `bun:"slap_version,notnull,default:'rebound'"` + ID int `bun:"id,pk,autoincrement" json:"id"` + Name string `bun:"name,unique,notnull" json:"name"` + ShortName string `bun:"short_name,unique,notnull" json:"short_name"` + StartDate time.Time `bun:"start_date,notnull" json:"start_date"` + EndDate bun.NullTime `bun:"end_date" json:"end_date"` + FinalsStartDate bun.NullTime `bun:"finals_start_date" json:"finals_start_date"` + FinalsEndDate bun.NullTime `bun:"finals_end_date" json:"finals_end_date"` + SlapVersion string `bun:"slap_version,notnull,default:'rebound'" json:"slap_version"` - Leagues []League `bun:"m2m:season_leagues,join:Season=League"` - Teams []Team `bun:"m2m:team_participations,join:Season=Team"` + Leagues []League `bun:"m2m:season_leagues,join:Season=League" json:"-"` + Teams []Team `bun:"m2m:team_participations,join:Season=Team" json:"-"` } // NewSeason creats a new season @@ -163,11 +163,21 @@ func (s *Season) GetDefaultTab() string { } } -func (s *Season) HasLeague(leagueID int) bool { - for _, league := range s.Leagues { - if league.ID == leagueID { +func (s *Season) HasLeague(league *League) bool { + for _, league_ := range s.Leagues { + if league_.ID == league.ID { return true } } return false } + +func (s *Season) GetLeague(leagueShortName string) (*League, error) { + for _, league := range s.Leagues { + if league.ShortName == leagueShortName { + return &league, nil + } + } + return nil, BadRequestNotAssociated("season", "league", + "id", "short_name", s.ID, leagueShortName) +} diff --git a/internal/db/seasonleague.go b/internal/db/seasonleague.go index d0cfcba..7ab55ea 100644 --- a/internal/db/seasonleague.go +++ b/internal/db/seasonleague.go @@ -24,19 +24,14 @@ func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShor return nil, nil, nil, errors.New("league short_name cannot be empty") } - // Get the season season, err := GetSeason(ctx, tx, seasonShortName) if err != nil { return nil, nil, nil, errors.Wrap(err, "GetSeason") } - // Get the league - league, err := GetLeague(ctx, tx, leagueShortName) + league, err := season.GetLeague(leagueShortName) if err != nil { - return nil, nil, nil, errors.Wrap(err, "GetLeague") - } - if !season.HasLeague(league.ID) { - return nil, nil, nil, BadRequestNotAssociated("season", "league", seasonShortName, leagueShortName) + return nil, nil, nil, errors.Wrap(err, "season.GetLeague") } // Get all teams participating in this season+league @@ -63,8 +58,9 @@ func NewSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShor if err != nil { return errors.Wrap(err, "GetLeague") } - if season.HasLeague(league.ID) { - return BadRequestAssociated("season", "league", seasonShortName, leagueShortName) + if season.HasLeague(league) { + return BadRequestAssociated("season", "league", + "id", "id", season.ID, league.ID) } seasonLeague := &SeasonLeague{ SeasonID: season.ID, @@ -84,12 +80,9 @@ func NewSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShor } func (s *Season) RemoveLeague(ctx context.Context, tx bun.Tx, leagueShortName string, audit *AuditMeta) error { - league, err := GetLeague(ctx, tx, leagueShortName) + league, err := s.GetLeague(leagueShortName) if err != nil { - return errors.Wrap(err, "GetLeague") - } - if !s.HasLeague(league.ID) { - return errors.New("league not in season") + return errors.Wrap(err, "s.GetLeague") } info := &AuditInfo{ string(permissions.SeasonsRemoveLeague), @@ -107,3 +100,12 @@ func (s *Season) RemoveLeague(ctx context.Context, tx bun.Tx, leagueShortName st } return nil } + +func (t *Team) InTeams(teams []*Team) bool { + for _, team := range teams { + if t.ID == team.ID { + return true + } + } + return false +} diff --git a/internal/db/team.go b/internal/db/team.go index 2bd4aa9..b0d9054 100644 --- a/internal/db/team.go +++ b/internal/db/team.go @@ -9,14 +9,14 @@ import ( type Team struct { bun.BaseModel `bun:"table:teams,alias:t"` - ID int `bun:"id,pk,autoincrement"` - Name string `bun:"name,unique,notnull"` - ShortName string `bun:"short_name,notnull,unique:short_names"` - AltShortName string `bun:"alt_short_name,notnull,unique:short_names"` - Color string `bun:"color"` + ID int `bun:"id,pk,autoincrement" json:"id"` + Name string `bun:"name,unique,notnull" json:"name"` + ShortName string `bun:"short_name,notnull,unique:short_names" json:"short_name"` + AltShortName string `bun:"alt_short_name,notnull,unique:short_names" json:"alt_short_name"` + Color string `bun:"color" json:"color,omitempty"` - Seasons []Season `bun:"m2m:team_participations,join:Team=Season"` - Leagues []League `bun:"m2m:team_participations,join:Team=League"` + Seasons []Season `bun:"m2m:team_participations,join:Team=Season" json:"-"` + Leagues []League `bun:"m2m:team_participations,join:Team=League" json:"-"` } func NewTeam(ctx context.Context, tx bun.Tx, name, shortName, altShortName, color string, audit *AuditMeta) (*Team, error) { diff --git a/internal/db/teamparticipation.go b/internal/db/teamparticipation.go index d1cdd47..1f44e45 100644 --- a/internal/db/teamparticipation.go +++ b/internal/db/teamparticipation.go @@ -23,19 +23,17 @@ func NewTeamParticipation(ctx context.Context, tx bun.Tx, if err != nil { return nil, nil, nil, errors.Wrap(err, "GetSeason") } - league, err := GetLeague(ctx, tx, leagueShortName) + league, err := season.GetLeague(leagueShortName) if err != nil { - return nil, nil, nil, errors.Wrap(err, "GetLeague") - } - if !season.HasLeague(league.ID) { - return nil, nil, nil, BadRequestNotAssociated("season", "league", seasonShortName, leagueShortName) + return nil, nil, nil, errors.Wrap(err, "season.GetLeague") } team, err := GetTeam(ctx, tx, teamID) if err != nil { return nil, nil, nil, errors.Wrap(err, "GetTeam") } if team.InSeason(season.ID) { - return nil, nil, nil, BadRequestAssociated("season", "team", seasonShortName, teamID) + return nil, nil, nil, BadRequestAssociated("season", "team", + "id", "id", season.ID, team.ID) } participation := &TeamParticipation{ SeasonID: season.ID, diff --git a/internal/db/user.go b/internal/db/user.go index fb276d7..0a5bb23 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -15,12 +15,12 @@ import ( type User struct { bun.BaseModel `bun:"table:users,alias:u"` - ID int `bun:"id,pk,autoincrement"` // Integer ID (index primary key) - Username string `bun:"username,unique"` // Username (unique) - CreatedAt int64 `bun:"created_at"` // Epoch timestamp when the user was added to the database - DiscordID string `bun:"discord_id,unique"` + ID int `bun:"id,pk,autoincrement" json:"id"` + Username string `bun:"username,unique" json:"username"` + CreatedAt int64 `bun:"created_at" json:"created_at"` + DiscordID string `bun:"discord_id,unique" json:"discord_id"` - Roles []*Role `bun:"m2m:user_roles,join:User=Role"` + Roles []*Role `bun:"m2m:user_roles,join:User=Role" json:"-"` } func (u *User) GetID() int { diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 44ea2f9..2f3e384 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -383,6 +383,9 @@ .ml-4 { margin-left: calc(var(--spacing) * 4); } + .ml-auto { + margin-left: auto; + } .line-clamp-2 { overflow: hidden; display: -webkit-box; @@ -453,6 +456,15 @@ .max-h-\[90vh\] { max-height: 90vh; } + .max-h-\[600px\] { + max-height: 600px; + } + .min-h-48 { + min-height: calc(var(--spacing) * 48); + } + .min-h-96 { + min-height: calc(var(--spacing) * 96); + } .min-h-\[calc\(100vh-200px\)\] { min-height: calc(100vh - 200px); } @@ -477,6 +489,9 @@ .w-20 { width: calc(var(--spacing) * 20); } + .w-24 { + width: calc(var(--spacing) * 24); + } .w-26 { width: calc(var(--spacing) * 26); } @@ -570,6 +585,9 @@ .animate-spin { animation: var(--animate-spin); } + .cursor-grab { + cursor: grab; + } .cursor-not-allowed { cursor: not-allowed; } @@ -776,9 +794,19 @@ border-bottom-style: var(--tw-border-style); border-bottom-width: 2px; } + .border-dashed { + --tw-border-style: dashed; + border-style: dashed; + } .border-blue { border-color: var(--blue); } + .border-blue\/50 { + border-color: var(--blue); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--blue) 50%, transparent); + } + } .border-green { border-color: var(--green); } @@ -896,6 +924,12 @@ .bg-surface1 { background-color: var(--surface1); } + .bg-surface1\/30 { + background-color: var(--surface1); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--surface1) 30%, transparent); + } + } .bg-surface2 { background-color: var(--surface2); } @@ -1311,6 +1345,16 @@ } } } + .hover\:bg-red\/80 { + &:hover { + @media (hover: hover) { + background-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--red) 80%, transparent); + } + } + } + } .hover\:bg-sapphire\/75 { &:hover { @media (hover: hover) { @@ -1383,6 +1427,16 @@ } } } + .hover\:text-blue\/80 { + &:hover { + @media (hover: hover) { + color: var(--blue); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--blue) 80%, transparent); + } + } + } + } .hover\:text-green { &:hover { @media (hover: hover) { @@ -1400,6 +1454,13 @@ } } } + .hover\:text-red { + &:hover { + @media (hover: hover) { + color: var(--red); + } + } + } .hover\:text-red\/75 { &:hover { @media (hover: hover) { @@ -1410,6 +1471,16 @@ } } } + .hover\:text-red\/80 { + &:hover { + @media (hover: hover) { + color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--red) 80%, transparent); + } + } + } + } .hover\:text-subtext1 { &:hover { @media (hover: hover) { @@ -1461,6 +1532,11 @@ outline-style: none; } } + .active\:cursor-grabbing { + &:active { + cursor: grabbing; + } + } .disabled\:pointer-events-none { &:disabled { pointer-events: none; @@ -1688,6 +1764,11 @@ inset-inline-end: calc(var(--spacing) * 8); } } + .lg\:col-span-2 { + @media (width >= 64rem) { + grid-column: span 2 / span 2; + } + } .lg\:col-span-3 { @media (width >= 64rem) { grid-column: span 3 / span 3; diff --git a/internal/handlers/fixtures.go b/internal/handlers/fixtures.go new file mode 100644 index 0000000..fbc9aac --- /dev/null +++ b/internal/handlers/fixtures.go @@ -0,0 +1,173 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/notify" + "git.haelnorr.com/h/oslstats/internal/respond" + "git.haelnorr.com/h/oslstats/internal/validation" + "git.haelnorr.com/h/oslstats/internal/view/seasonsview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func GenerateFixtures( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + seasonShortName := getter.String("season_short_name").TrimSpace().Required().Value + leagueShortName := getter.String("league_short_name").TrimSpace().Required().Value + round := getter.Int("round").Required().Value + if !getter.ValidateAndNotify(s, w, r) { + return + } + + var season *db.Season + var league *db.League + var fixtures []*db.Fixture + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + _, err := db.NewRound(ctx, tx, seasonShortName, leagueShortName, round, db.NewAudit(r, nil)) + if err != nil { + if db.IsBadRequest(err) { + respond.BadRequest(w, errors.Wrap(err, "db.NewRound")) + return false, nil + } + return false, errors.Wrap(err, "db.NewRound") + } + season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName) + if err != nil { + return false, errors.Wrap(err, "db.GetFixtures") + } + return true, nil + }); !ok { + return + } + + renderSafely(seasonsview.SeasonLeagueManageFixtures(season, league, fixtures), s, r, w) + }) +} + +func UpdateFixtures( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + seasonShortName := getter.String("season_short_name").TrimSpace().Required().Value + leagueShortName := getter.String("league_short_name").TrimSpace().Required().Value + allocations := getter.GetMaps("allocations") + if !getter.ValidateAndNotify(s, w, r) { + return + } + updates, err := mapUpdates(allocations) + if err != nil { + respond.BadRequest(w, errors.Wrap(err, "strconv.Atoi")) + return + } + + var fixtures []*db.Fixture + + if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + _, _, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName) + if err != nil { + if db.IsBadRequest(err) { + respond.BadRequest(w, errors.Wrap(err, "db.NewRound")) + return false, nil + } + return false, errors.Wrap(err, "db.GetFixtures") + } + var valid bool + fixtures, valid = updateFixtures(fixtures, updates) + if !valid { + notify.Warn(s, w, r, "Invalid game weeks", "A game week is missing or has no games", nil) + return false, nil + } + err = db.UpdateFixtureGameWeeks(ctx, tx, fixtures, db.NewAudit(r, nil)) + if err != nil { + if db.IsBadRequest(err) { + respond.BadRequest(w, errors.Wrap(err, "db.UpdateFixtureGameWeeks")) + } + return false, errors.Wrap(err, "db.UpdateFixtureGameWeeks") + } + return true, nil + }) { + return + } + notify.Success(s, w, r, "Fixtures Updated", "Fixtures successfully updated", nil) + }) +} + +func DeleteFixture( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixtureIDstr := r.PathValue("fixture_id") + fixtureID, err := strconv.Atoi(fixtureIDstr) + if err != nil { + respond.BadRequest(w, errors.Wrap(err, "strconv.Atoi")) + return + } + if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + err := db.DeleteFixture(ctx, tx, fixtureID, db.NewAudit(r, nil)) + if err != nil { + if db.IsBadRequest(err) { + respond.NotFound(w, errors.Wrap(err, "db.DeleteFixture")) + return false, nil + } + return false, errors.Wrap(err, "db.DeleteFixture") + } + return true, nil + }) { + return + } + }) +} + +func mapUpdates(allocations []map[string]string) (map[int]int, error) { + updates := map[int]int{} + for _, v := range allocations { + id, err := strconv.Atoi(v["id"]) + if err != nil { + return nil, errors.Wrap(err, "strconv.Atoi") + } + gameWeek, err := strconv.Atoi(v["game_week"]) + if err != nil { + return nil, errors.Wrap(err, "strconv.Atoi") + } + updates[id] = gameWeek + } + return updates, nil +} + +func updateFixtures(fixtures []*db.Fixture, updates map[int]int) ([]*db.Fixture, bool) { + updated := []*db.Fixture{} + gameWeeks := map[int]int{} + for _, fixture := range fixtures { + if gameWeek, exists := updates[fixture.ID]; exists { + fixture.GameWeek = &gameWeek + updated = append(updated, fixture) + } + gameWeeks[*fixture.GameWeek]++ + } + for i := range len(gameWeeks) { + count, exists := gameWeeks[i+1] + if !exists || count < 1 { + return nil, false + } + } + return updated, true +} diff --git a/internal/handlers/season_league_fixtures.go b/internal/handlers/season_league_fixtures.go index 24c0f0f..f60420b 100644 --- a/internal/handlers/season_league_fixtures.go +++ b/internal/handlers/season_league_fixtures.go @@ -6,8 +6,9 @@ import ( "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" - seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview" + "git.haelnorr.com/h/oslstats/internal/view/seasonsview" "github.com/pkg/errors" "github.com/uptrace/bun" ) @@ -18,21 +19,22 @@ func SeasonLeagueFixturesPage( 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") + seasonShortName := r.PathValue("season_short_name") + leagueShortName := r.PathValue("league_short_name") var season *db.Season var league *db.League + var fixtures []*db.Fixture if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error - season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) + season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName) if err != nil { if db.IsBadRequest(err) { throw.NotFound(s, w, r, r.URL.Path) return false, nil } - return false, errors.Wrap(err, "db.GetSeasonLeague") + return false, errors.Wrap(err, "db.GetFixtures") } return true, nil }); !ok { @@ -40,9 +42,73 @@ func SeasonLeagueFixturesPage( } if r.Method == "GET" { - renderSafely(seasonsview.SeasonLeagueFixturesPage(season, league), s, r, w) + renderSafely(seasonsview.SeasonLeagueFixturesPage(season, league, fixtures), s, r, w) } else { - renderSafely(seasonsview.SeasonLeagueFixtures(), s, r, w) + renderSafely(seasonsview.SeasonLeagueFixtures(season, league, fixtures), s, r, w) } }) } + +func SeasonLeagueManageFixturesPage( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seasonShortName := r.PathValue("season_short_name") + leagueShortName := r.PathValue("league_short_name") + + var season *db.Season + var league *db.League + var fixtures []*db.Fixture + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName) + if err != nil { + if db.IsBadRequest(err) { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return false, errors.Wrap(err, "db.GetFixtures") + } + return true, nil + }); !ok { + return + } + + renderSafely(seasonsview.SeasonLeagueManageFixturesPage(season, league, fixtures), s, r, w) + }) +} + +func SeasonLeagueDeleteFixtures( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seasonShortName := r.PathValue("season_short_name") + leagueShortName := r.PathValue("league_short_name") + + var season *db.Season + var league *db.League + var fixtures []*db.Fixture + if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + err := db.DeleteAllFixtures(ctx, tx, seasonShortName, leagueShortName, db.NewAudit(r, nil)) + if err != nil { + if db.IsBadRequest(err) { + respond.BadRequest(w, errors.Wrap(err, "db.DeleteAllFixtures")) + return false, nil + } + return false, errors.Wrap(err, "db.DeleteAllFixtures") + } + season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName) + if err != nil { + return false, errors.Wrap(err, "db.GetFixtures") + } + return true, nil + }) { + return + } + + renderSafely(seasonsview.SeasonLeagueManageFixtures(season, league, fixtures), s, r, w) + }) +} diff --git a/internal/handlers/teams_new.go b/internal/handlers/teams_new.go index 9bf8fd4..442fe2f 100644 --- a/internal/handlers/teams_new.go +++ b/internal/handlers/teams_new.go @@ -89,7 +89,7 @@ func NewTeamSubmit( notify.Warn(s, w, r, "Duplicate Short Names", "This combination of short names is already taken.", nil) return } - respond.HXRedirect(w, "teams") + respond.HXRedirect(w, "/teams") notify.SuccessWithDelay(s, w, r, "Team Created", fmt.Sprintf("Successfully created team: %s", name), nil) }) } diff --git a/internal/permissions/constants.go b/internal/permissions/constants.go index b54db87..f7114d1 100644 --- a/internal/permissions/constants.go +++ b/internal/permissions/constants.go @@ -33,4 +33,9 @@ const ( UsersUpdate Permission = "users.update" UsersBan Permission = "users.ban" UsersManageRoles Permission = "users.manage_roles" + + // Fixtures permissions + FixturesManage Permission = "fixtures.manage" + FixturesCreate Permission = "fixtures.create" + FixturesDelete Permission = "fixtures.delete" ) diff --git a/internal/server/routes.go b/internal/server/routes.go index a1f5440..4ad52c4 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -160,6 +160,34 @@ func addRoutes( }, } + fixturesRoutes := []hws.Route{ + { + Path: "/seasons/{season_short_name}/leagues/{league_short_name}/fixtures/manage", + Method: hws.MethodGET, + Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.SeasonLeagueManageFixturesPage(s, conn)), + }, + { + Path: "/fixtures/generate", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.FixturesCreate)(handlers.GenerateFixtures(s, conn)), + }, + { + Path: "/fixtures/update-game-weeks", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.UpdateFixtures(s, conn)), + }, + { + Path: "/seasons/{season_short_name}/leagues/{league_short_name}/fixtures", + Method: hws.MethodDELETE, + Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.SeasonLeagueDeleteFixtures(s, conn)), + }, + { + Path: "/fixtures/{fixture_id}", + Method: hws.MethodDELETE, + Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)), + }, + } + teamRoutes := []hws.Route{ { Path: "/teams", @@ -321,6 +349,7 @@ func addRoutes( routes = append(routes, adminRoutes...) routes = append(routes, seasonRoutes...) routes = append(routes, leagueRoutes...) + routes = append(routes, fixturesRoutes...) routes = append(routes, teamRoutes...) // Register the routes with the server diff --git a/internal/validation/forms.go b/internal/validation/forms.go index 7f59de6..c691e79 100644 --- a/internal/validation/forms.go +++ b/internal/validation/forms.go @@ -2,6 +2,8 @@ package validation import ( "net/http" + "regexp" + "strconv" "strings" "git.haelnorr.com/h/golib/hws" @@ -29,6 +31,22 @@ func (f *FormGetter) GetList(key string) []string { return strings.Split(f.Get(key), ",") } +func (f *FormGetter) GetMaps(key string) []map[string]string { + var result []map[string]string + for key, values := range f.r.Form { + re := regexp.MustCompile(key + "\\[([0-9]+)\\]\\[([a-zA-Z]+)\\]") + matches := re.FindStringSubmatch(key) + if len(matches) >= 3 { + index, _ := strconv.Atoi(matches[1]) + for index >= len(result) { + result = append(result, map[string]string{}) + } + result[index][matches[2]] = values[0] + } + } + return result +} + func (f *FormGetter) getChecks() []*ValidationRule { return f.checks } diff --git a/internal/view/seasonsview/season_league_fixtures.templ b/internal/view/seasonsview/season_league_fixtures.templ index b3286d8..7db9f36 100644 --- a/internal/view/seasonsview/season_league_fixtures.templ +++ b/internal/view/seasonsview/season_league_fixtures.templ @@ -1,15 +1,85 @@ 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 "fmt" -templ SeasonLeagueFixturesPage(season *db.Season, league *db.League) { +templ SeasonLeagueFixturesPage(season *db.Season, league *db.League, fixtures []*db.Fixture) { @SeasonLeagueLayout("fixtures", season, league) { - @SeasonLeagueFixtures() + @SeasonLeagueFixtures(season, league, fixtures) } } -templ SeasonLeagueFixtures() { -
-

Coming Soon...

+templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture) { + {{ + permCache := contexts.Permissions(ctx) + canManage := permCache.HasPermission(permissions.FixturesManage) + + // Group fixtures by game week (only allocated ones) + type gameWeekGroup struct { + Week int + Fixtures []*db.Fixture + } + groups := []gameWeekGroup{} + groupMap := map[int]int{} // week -> index in groups + for _, f := range fixtures { + if f.GameWeek == nil { + continue + } + idx, exists := groupMap[*f.GameWeek] + if !exists { + idx = len(groups) + groupMap[*f.GameWeek] = idx + groups = append(groups, gameWeekGroup{Week: *f.GameWeek, Fixtures: []*db.Fixture{}}) + } + groups[idx].Fixtures = append(groups[idx].Fixtures, f) + } + }} +
+ if canManage { + + } + if len(groups) == 0 { +
+

No fixtures scheduled yet.

+
+ } else { +
+ for _, group := range groups { +
+
+

Game Week { fmt.Sprint(group.Week) }

+
+
+ for _, fixture := range group.Fixtures { +
+
+ + R{ fmt.Sprint(fixture.Round) } + + + { fixture.HomeTeam.Name } + + vs + + { fixture.AwayTeam.Name } + +
+
+ } +
+
+ } +
+ }
} diff --git a/internal/view/seasonsview/season_league_fixtures_manage.templ b/internal/view/seasonsview/season_league_fixtures_manage.templ new file mode 100644 index 0000000..3697c55 --- /dev/null +++ b/internal/view/seasonsview/season_league_fixtures_manage.templ @@ -0,0 +1,477 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "encoding/json" +import "fmt" + +type FixtureJSON struct { + ID int `json:"id"` + HomeTeam string `json:"homeTeam"` + AwayTeam string `json:"awayTeam"` + Round int `json:"round"` + GameWeek *int `json:"gameWeek"` +} + +func fixtureToJSON(f *db.Fixture) FixtureJSON { + return FixtureJSON{ + ID: f.ID, + HomeTeam: f.HomeTeam.Name, + AwayTeam: f.AwayTeam.Name, + Round: f.Round, + GameWeek: f.GameWeek, + } +} + +func fixturesToJSON(fixtures []*db.Fixture) string { + data := make([]FixtureJSON, len(fixtures)) + for i, f := range fixtures { + data[i] = fixtureToJSON(f) + } + b, _ := json.Marshal(data) + return string(b) +} + +templ SeasonLeagueManageFixturesPage(season *db.Season, league *db.League, fixtures []*db.Fixture) { + @baseview.Layout(fmt.Sprintf("%s - %s - Manage Fixtures", season.Name, league.Name)) { +
+
+ + ← Back to Fixtures + +

Manage Fixtures - { season.Name } - { league.Name }

+
+ @SeasonLeagueManageFixtures(season, league, fixtures) +
+ } +} + +templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture) { +
+ +
+ +
+ + +
+ + + + + + All game weeks must have at least 1 fixture + +
+ +
+ +
+ +
+

Game Weeks

+
+ No game weeks yet. Add one to start allocating fixtures. +
+
+ +
+ + +
+ +
+
+ +

+ + ( + ) + + + +
+ +
+
+ Drop fixtures here +
+
+ +
+
+
+
+ +
+

+ Unallocated + + () + +

+
+
+ No fixtures generated yet. + All fixtures allocated! +
+ +
+
+
+ + +
+}