we have fixtures ladies and gentleman

This commit is contained in:
2026-02-15 19:56:03 +11:00
parent c5f6fe6098
commit 366aebfdf3
23 changed files with 1355 additions and 78 deletions

View File

@@ -23,7 +23,7 @@ type AuditInfo struct {
Action string // e.g., "seasons.create", "users.update" Action string // e.g., "seasons.create", "users.update"
ResourceType string // e.g., "season", "user" ResourceType string // e.g., "season", "user"
ResourceID any // Primary key value (int, string, etc.) ResourceID any // Primary key value (int, string, etc.)
Details map[string]any // Changed fields or additional metadata Details any // Changed fields or additional metadata
} }
// extractTableName gets the bun table name from a model type using reflection // extractTableName gets the bun table name from a model type using reflection

View File

@@ -6,7 +6,7 @@ import (
) )
func IsBadRequest(err error) bool { func IsBadRequest(err error) bool {
return strings.HasPrefix(err.Error(), "bad request:") return strings.Contains(err.Error(), "bad request:")
} }
func BadRequest(err string) error { func BadRequest(err string) error {
@@ -18,14 +18,14 @@ func BadRequestNotFound(resource, field string, value any) error {
return BadRequest(errStr) return BadRequest(errStr)
} }
func BadRequestNotAssociated(parent, child string, parentID, childID any) error { func BadRequestNotAssociated(parent, child, parentField, childField string, parentID, childID any) error {
errStr := fmt.Sprintf("%s (ID: %v) not associated with %s (ID: %v)", errStr := fmt.Sprintf("%s with %s=%v not associated to %s with %s=%v",
child, childID, parent, parentID) child, childField, childID, parent, parentField, parentID)
return BadRequest(errStr) return BadRequest(errStr)
} }
func BadRequestAssociated(parent, child string, parentID, childID any) error { func BadRequestAssociated(parent, child, parentField, childField string, parentID, childID any) error {
errStr := fmt.Sprintf("%s (ID: %v) already associated with %s (ID: %v)", errStr := fmt.Sprintf("%s with %s=%v already associated to %s with %s=%v",
child, childID, parent, parentID) child, childField, childID, parent, parentField, parentID)
return BadRequest(errStr) return BadRequest(errStr)
} }

282
internal/db/fixture.go Normal file
View File

@@ -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
}

View File

@@ -92,6 +92,11 @@ func (l *listgetter[T]) Where(query string, args ...any) *listgetter[T] {
return l 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] { func (l *listgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *listgetter[T] {
l.q = l.q.Relation(name, apply...) l.q = l.q.Relation(name, apply...)
return l return l
@@ -130,6 +135,14 @@ func (l *listgetter[T]) GetPaged(ctx context.Context, pageOpts, defaults *PageOp
return list, nil 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) { func (l *listgetter[T]) GetAll(ctx context.Context) ([]*T, error) {
err := l.q.Scan(ctx) err := l.q.Scan(ctx)
if err != nil && errors.Is(err, sql.ErrNoRows) { if err != nil && errors.Is(err, sql.ErrNoRows) {

View File

@@ -109,6 +109,7 @@ func (i *inserter[T]) Exec(ctx context.Context) error {
} }
} else { } else {
i.auditInfo.ResourceID = extractPrimaryKey(i.model) i.auditInfo.ResourceID = extractPrimaryKey(i.model)
i.auditInfo.Details = i.model
} }
} }

View File

@@ -10,13 +10,13 @@ import (
type League struct { type League struct {
bun.BaseModel `bun:"table:leagues,alias:l"` bun.BaseModel `bun:"table:leagues,alias:l"`
ID int `bun:"id,pk,autoincrement"` ID int `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,unique,notnull"` Name string `bun:"name,unique,notnull" json:"name"`
ShortName string `bun:"short_name,unique,notnull"` ShortName string `bun:"short_name,unique,notnull" json:"short_name"`
Description string `bun:"description"` Description string `bun:"description" json:"description"`
Seasons []Season `bun:"m2m:season_leagues,join:League=Season"` Seasons []Season `bun:"m2m:season_leagues,join:League=Season" json:"-"`
Teams []Team `bun:"m2m:team_participations,join:League=Team"` Teams []Team `bun:"m2m:team_participations,join:League=Team" json:"-"`
} }
func GetLeagues(ctx context.Context, tx bun.Tx) ([]*League, error) { func GetLeagues(ctx context.Context, tx bun.Tx) ([]*League, error) {

View File

@@ -256,7 +256,7 @@ func migrateStatus(ctx context.Context, migrator *migrate.Migrator) error {
// validateMigrations ensures migrations compile before running // validateMigrations ensures migrations compile before running
func validateMigrations(ctx context.Context) error { func validateMigrations(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "go", "build", cmd := exec.CommandContext(ctx, "go", "build",
"-o", "/dev/null", "./cmd/oslstats/migrations") "-o", "/dev/null", "./internal/db/migrations")
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {

View File

@@ -31,12 +31,12 @@ func init() {
Model(&permissionsData). Model(&permissionsData).
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "dbConn.NewInsert") return errors.Wrap(err, "conn.NewInsert")
} }
return nil return nil
}, },
// DOWN migration // DOWN migration
func(ctx context.Context, dbConn *bun.DB) error { func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here // Add your rollback code here
return nil return nil
}, },

View File

@@ -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
},
)
}

View File

@@ -28,17 +28,17 @@ const (
type Season struct { type Season struct {
bun.BaseModel `bun:"table:seasons,alias:s"` bun.BaseModel `bun:"table:seasons,alias:s"`
ID int `bun:"id,pk,autoincrement"` ID int `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,unique,notnull"` Name string `bun:"name,unique,notnull" json:"name"`
ShortName string `bun:"short_name,unique,notnull"` ShortName string `bun:"short_name,unique,notnull" json:"short_name"`
StartDate time.Time `bun:"start_date,notnull"` StartDate time.Time `bun:"start_date,notnull" json:"start_date"`
EndDate bun.NullTime `bun:"end_date"` EndDate bun.NullTime `bun:"end_date" json:"end_date"`
FinalsStartDate bun.NullTime `bun:"finals_start_date"` FinalsStartDate bun.NullTime `bun:"finals_start_date" json:"finals_start_date"`
FinalsEndDate bun.NullTime `bun:"finals_end_date"` FinalsEndDate bun.NullTime `bun:"finals_end_date" json:"finals_end_date"`
SlapVersion string `bun:"slap_version,notnull,default:'rebound'"` SlapVersion string `bun:"slap_version,notnull,default:'rebound'" json:"slap_version"`
Leagues []League `bun:"m2m:season_leagues,join:Season=League"` Leagues []League `bun:"m2m:season_leagues,join:Season=League" json:"-"`
Teams []Team `bun:"m2m:team_participations,join:Season=Team"` Teams []Team `bun:"m2m:team_participations,join:Season=Team" json:"-"`
} }
// NewSeason creats a new season // NewSeason creats a new season
@@ -163,11 +163,21 @@ func (s *Season) GetDefaultTab() string {
} }
} }
func (s *Season) HasLeague(leagueID int) bool { func (s *Season) HasLeague(league *League) bool {
for _, league := range s.Leagues { for _, league_ := range s.Leagues {
if league.ID == leagueID { if league_.ID == league.ID {
return true return true
} }
} }
return false 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)
}

View File

@@ -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") return nil, nil, nil, errors.New("league short_name cannot be empty")
} }
// Get the season
season, err := GetSeason(ctx, tx, seasonShortName) season, err := GetSeason(ctx, tx, seasonShortName)
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeason") return nil, nil, nil, errors.Wrap(err, "GetSeason")
} }
// Get the league league, err := season.GetLeague(leagueShortName)
league, err := GetLeague(ctx, tx, leagueShortName)
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetLeague") return nil, nil, nil, errors.Wrap(err, "season.GetLeague")
}
if !season.HasLeague(league.ID) {
return nil, nil, nil, BadRequestNotAssociated("season", "league", seasonShortName, leagueShortName)
} }
// Get all teams participating in this season+league // 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 { if err != nil {
return errors.Wrap(err, "GetLeague") return errors.Wrap(err, "GetLeague")
} }
if season.HasLeague(league.ID) { if season.HasLeague(league) {
return BadRequestAssociated("season", "league", seasonShortName, leagueShortName) return BadRequestAssociated("season", "league",
"id", "id", season.ID, league.ID)
} }
seasonLeague := &SeasonLeague{ seasonLeague := &SeasonLeague{
SeasonID: season.ID, 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 { 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 { if err != nil {
return errors.Wrap(err, "GetLeague") return errors.Wrap(err, "s.GetLeague")
}
if !s.HasLeague(league.ID) {
return errors.New("league not in season")
} }
info := &AuditInfo{ info := &AuditInfo{
string(permissions.SeasonsRemoveLeague), string(permissions.SeasonsRemoveLeague),
@@ -107,3 +100,12 @@ func (s *Season) RemoveLeague(ctx context.Context, tx bun.Tx, leagueShortName st
} }
return nil return nil
} }
func (t *Team) InTeams(teams []*Team) bool {
for _, team := range teams {
if t.ID == team.ID {
return true
}
}
return false
}

View File

@@ -9,14 +9,14 @@ import (
type Team struct { type Team struct {
bun.BaseModel `bun:"table:teams,alias:t"` bun.BaseModel `bun:"table:teams,alias:t"`
ID int `bun:"id,pk,autoincrement"` ID int `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,unique,notnull"` Name string `bun:"name,unique,notnull" json:"name"`
ShortName string `bun:"short_name,notnull,unique:short_names"` ShortName string `bun:"short_name,notnull,unique:short_names" json:"short_name"`
AltShortName string `bun:"alt_short_name,notnull,unique:short_names"` AltShortName string `bun:"alt_short_name,notnull,unique:short_names" json:"alt_short_name"`
Color string `bun:"color"` Color string `bun:"color" json:"color,omitempty"`
Seasons []Season `bun:"m2m:team_participations,join:Team=Season"` Seasons []Season `bun:"m2m:team_participations,join:Team=Season" json:"-"`
Leagues []League `bun:"m2m:team_participations,join:Team=League"` 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) { func NewTeam(ctx context.Context, tx bun.Tx, name, shortName, altShortName, color string, audit *AuditMeta) (*Team, error) {

View File

@@ -23,19 +23,17 @@ func NewTeamParticipation(ctx context.Context, tx bun.Tx,
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeason") return nil, nil, nil, errors.Wrap(err, "GetSeason")
} }
league, err := GetLeague(ctx, tx, leagueShortName) league, err := season.GetLeague(leagueShortName)
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetLeague") return nil, nil, nil, errors.Wrap(err, "season.GetLeague")
}
if !season.HasLeague(league.ID) {
return nil, nil, nil, BadRequestNotAssociated("season", "league", seasonShortName, leagueShortName)
} }
team, err := GetTeam(ctx, tx, teamID) team, err := GetTeam(ctx, tx, teamID)
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetTeam") return nil, nil, nil, errors.Wrap(err, "GetTeam")
} }
if team.InSeason(season.ID) { 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{ participation := &TeamParticipation{
SeasonID: season.ID, SeasonID: season.ID,

View File

@@ -15,12 +15,12 @@ import (
type User struct { type User struct {
bun.BaseModel `bun:"table:users,alias:u"` bun.BaseModel `bun:"table:users,alias:u"`
ID int `bun:"id,pk,autoincrement"` // Integer ID (index primary key) ID int `bun:"id,pk,autoincrement" json:"id"`
Username string `bun:"username,unique"` // Username (unique) Username string `bun:"username,unique" json:"username"`
CreatedAt int64 `bun:"created_at"` // Epoch timestamp when the user was added to the database CreatedAt int64 `bun:"created_at" json:"created_at"`
DiscordID string `bun:"discord_id,unique"` 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 { func (u *User) GetID() int {

View File

@@ -383,6 +383,9 @@
.ml-4 { .ml-4 {
margin-left: calc(var(--spacing) * 4); margin-left: calc(var(--spacing) * 4);
} }
.ml-auto {
margin-left: auto;
}
.line-clamp-2 { .line-clamp-2 {
overflow: hidden; overflow: hidden;
display: -webkit-box; display: -webkit-box;
@@ -453,6 +456,15 @@
.max-h-\[90vh\] { .max-h-\[90vh\] {
max-height: 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-h-\[calc\(100vh-200px\)\] {
min-height: calc(100vh - 200px); min-height: calc(100vh - 200px);
} }
@@ -477,6 +489,9 @@
.w-20 { .w-20 {
width: calc(var(--spacing) * 20); width: calc(var(--spacing) * 20);
} }
.w-24 {
width: calc(var(--spacing) * 24);
}
.w-26 { .w-26 {
width: calc(var(--spacing) * 26); width: calc(var(--spacing) * 26);
} }
@@ -570,6 +585,9 @@
.animate-spin { .animate-spin {
animation: var(--animate-spin); animation: var(--animate-spin);
} }
.cursor-grab {
cursor: grab;
}
.cursor-not-allowed { .cursor-not-allowed {
cursor: not-allowed; cursor: not-allowed;
} }
@@ -776,9 +794,19 @@
border-bottom-style: var(--tw-border-style); border-bottom-style: var(--tw-border-style);
border-bottom-width: 2px; border-bottom-width: 2px;
} }
.border-dashed {
--tw-border-style: dashed;
border-style: dashed;
}
.border-blue { .border-blue {
border-color: var(--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-green {
border-color: var(--green); border-color: var(--green);
} }
@@ -896,6 +924,12 @@
.bg-surface1 { .bg-surface1 {
background-color: var(--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 { .bg-surface2 {
background-color: var(--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\:bg-sapphire\/75 {
&:hover { &:hover {
@media (hover: 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\:text-green {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1400,6 +1454,13 @@
} }
} }
} }
.hover\:text-red {
&:hover {
@media (hover: hover) {
color: var(--red);
}
}
}
.hover\:text-red\/75 { .hover\:text-red\/75 {
&:hover { &:hover {
@media (hover: 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\:text-subtext1 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1461,6 +1532,11 @@
outline-style: none; outline-style: none;
} }
} }
.active\:cursor-grabbing {
&:active {
cursor: grabbing;
}
}
.disabled\:pointer-events-none { .disabled\:pointer-events-none {
&:disabled { &:disabled {
pointer-events: none; pointer-events: none;
@@ -1688,6 +1764,11 @@
inset-inline-end: calc(var(--spacing) * 8); inset-inline-end: calc(var(--spacing) * 8);
} }
} }
.lg\:col-span-2 {
@media (width >= 64rem) {
grid-column: span 2 / span 2;
}
}
.lg\:col-span-3 { .lg\:col-span-3 {
@media (width >= 64rem) { @media (width >= 64rem) {
grid-column: span 3 / span 3; grid-column: span 3 / span 3;

View File

@@ -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
}

View File

@@ -6,8 +6,9 @@ import (
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db" "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/throw"
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview" "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
@@ -18,21 +19,22 @@ func SeasonLeagueFixturesPage(
conn *db.DB, conn *db.DB,
) http.Handler { ) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name") seasonShortName := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name") leagueShortName := r.PathValue("league_short_name")
var season *db.Season var season *db.Season
var league *db.League var league *db.League
var fixtures []*db.Fixture
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err 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 err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path) throw.NotFound(s, w, r, r.URL.Path)
return false, nil return false, nil
} }
return false, errors.Wrap(err, "db.GetSeasonLeague") return false, errors.Wrap(err, "db.GetFixtures")
} }
return true, nil return true, nil
}); !ok { }); !ok {
@@ -40,9 +42,73 @@ func SeasonLeagueFixturesPage(
} }
if r.Method == "GET" { if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueFixturesPage(season, league), s, r, w) renderSafely(seasonsview.SeasonLeagueFixturesPage(season, league, fixtures), s, r, w)
} else { } 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)
})
}

View File

@@ -89,7 +89,7 @@ func NewTeamSubmit(
notify.Warn(s, w, r, "Duplicate Short Names", "This combination of short names is already taken.", nil) notify.Warn(s, w, r, "Duplicate Short Names", "This combination of short names is already taken.", nil)
return return
} }
respond.HXRedirect(w, "teams") respond.HXRedirect(w, "/teams")
notify.SuccessWithDelay(s, w, r, "Team Created", fmt.Sprintf("Successfully created team: %s", name), nil) notify.SuccessWithDelay(s, w, r, "Team Created", fmt.Sprintf("Successfully created team: %s", name), nil)
}) })
} }

View File

@@ -33,4 +33,9 @@ const (
UsersUpdate Permission = "users.update" UsersUpdate Permission = "users.update"
UsersBan Permission = "users.ban" UsersBan Permission = "users.ban"
UsersManageRoles Permission = "users.manage_roles" UsersManageRoles Permission = "users.manage_roles"
// Fixtures permissions
FixturesManage Permission = "fixtures.manage"
FixturesCreate Permission = "fixtures.create"
FixturesDelete Permission = "fixtures.delete"
) )

View File

@@ -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{ teamRoutes := []hws.Route{
{ {
Path: "/teams", Path: "/teams",
@@ -321,6 +349,7 @@ func addRoutes(
routes = append(routes, adminRoutes...) routes = append(routes, adminRoutes...)
routes = append(routes, seasonRoutes...) routes = append(routes, seasonRoutes...)
routes = append(routes, leagueRoutes...) routes = append(routes, leagueRoutes...)
routes = append(routes, fixturesRoutes...)
routes = append(routes, teamRoutes...) routes = append(routes, teamRoutes...)
// Register the routes with the server // Register the routes with the server

View File

@@ -2,6 +2,8 @@ package validation
import ( import (
"net/http" "net/http"
"regexp"
"strconv"
"strings" "strings"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
@@ -29,6 +31,22 @@ func (f *FormGetter) GetList(key string) []string {
return strings.Split(f.Get(key), ",") 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 { func (f *FormGetter) getChecks() []*ValidationRule {
return f.checks return f.checks
} }

View File

@@ -1,15 +1,85 @@
package seasonsview package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db" 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) { @SeasonLeagueLayout("fixtures", season, league) {
@SeasonLeagueFixtures() @SeasonLeagueFixtures(season, league, fixtures)
} }
} }
templ SeasonLeagueFixtures() { 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)
}
}}
<div>
if canManage {
<div class="flex justify-end mb-4">
<a
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/fixtures/manage", season.ShortName, league.ShortName)) }
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-blue hover:bg-blue/80 text-mantle transition"
>
Manage Fixtures
</a>
</div>
}
if len(groups) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center"> <div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">Coming Soon...</p> <p class="text-subtext0 text-lg">No fixtures scheduled yet.</p>
</div>
} else {
<div class="space-y-4">
for _, group := range groups {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
<div class="bg-mantle border-b border-surface1 px-4 py-3">
<h3 class="text-lg font-bold text-text">Game Week { fmt.Sprint(group.Week) }</h3>
</div>
<div class="divide-y divide-surface1">
for _, fixture := range group.Fixtures {
<div class="px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded">
R{ fmt.Sprint(fixture.Round) }
</span>
<span class="text-text">
{ fixture.HomeTeam.Name }
</span>
<span class="text-subtext0 text-sm">vs</span>
<span class="text-text">
{ fixture.AwayTeam.Name }
</span>
</div>
</div>
}
</div>
</div>
}
</div>
}
</div> </div>
} }

View File

@@ -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)) {
<div class="max-w-screen-2xl mx-auto px-4 py-8">
<div class="flex items-center gap-4 mb-6">
<a
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", season.ShortName, league.ShortName)) }
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-surface1 hover:bg-surface2 text-text transition"
>
&larr; Back to Fixtures
</a>
<h1 class="text-2xl font-bold text-text">Manage Fixtures - { season.Name } - { league.Name }</h1>
</div>
@SeasonLeagueManageFixtures(season, league, fixtures)
</div>
}
}
templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture) {
<div
id="manage-fixtures-content"
x-data={ fmt.Sprintf("fixturesManager(%s, '%s', '%s')", fixturesToJSON(fixtures), season.ShortName, league.ShortName) }
x-cloak
>
<!-- Controls -->
<div class="flex items-center gap-3 mb-6">
<!-- Generate -->
<div class="flex items-center gap-2">
<input
type="number"
x-model.number="generateRounds"
min="1"
max="20"
placeholder="Rounds"
class="w-24 py-2 px-3 rounded-lg text-sm bg-base border-2 border-overlay0
focus:border-blue outline-none text-text"
/>
<button
@click="generate()"
:disabled="isGenerating || generateRounds < 1"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-blue hover:bg-blue/80 text-mantle transition
disabled:bg-blue/40 disabled:cursor-not-allowed"
>
<span x-text="isGenerating ? 'Generating...' : 'Generate Round'"></span>
</button>
</div>
<!-- Clear All -->
<button
x-show="allFixtures.length > 0"
@click="clearAll()"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-red hover:bg-red/80 text-mantle transition"
>
Clear All
</button>
<!-- Save -->
<button
x-show="unsavedChanges"
@click="save()"
:disabled="isSaving || !canSave()"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-green hover:bg-green/75 text-mantle transition
disabled:bg-green/40 disabled:cursor-not-allowed ml-auto"
>
<span x-text="isSaving ? 'Saving...' : 'Save'"></span>
</button>
<span
x-show="unsavedChanges && !canSave()"
class="text-yellow text-xs ml-2"
>
All game weeks must have at least 1 fixture
</span>
</div>
<!-- Main content panels -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- LEFT PANEL: Game Weeks -->
<div class="lg:col-span-2 bg-surface0 border border-surface1 rounded-lg p-4 min-h-96">
<!-- List View -->
<div x-show="selectedGameWeek === null">
<h3 class="text-lg font-bold text-text mb-4">Game Weeks</h3>
<div x-show="allGameWeekNumbers.length === 0" class="text-subtext0 text-sm italic mb-4">
No game weeks yet. Add one to start allocating fixtures.
</div>
<div class="space-y-2">
<template x-for="week in allGameWeekNumbers" :key="week">
<div
class="bg-mantle border border-surface1 rounded-lg p-4
hover:bg-surface1 transition group relative"
:class="{ 'border-blue/50': dropTarget === week }"
@click="selectGameWeek(week)"
@dragover.prevent="dropTarget = week"
@dragleave="if (dropTarget === week) dropTarget = null"
@drop.prevent="onDrop(week)"
>
<div class="flex items-center justify-between hover:cursor-pointer">
<div>
<span class="font-semibold text-text" x-text="'Game Week ' + week"></span>
<span class="text-subtext0 text-sm ml-2">
(<span x-text="getFixtureCount(week)"></span>
<span x-text="getFixtureCount(week) === 1 ? 'fixture' : 'fixtures'"></span>)
</span>
</div>
<!-- Preview of first few fixtures -->
<div class="flex items-center gap-2">
<template x-for="f in getPreview(week)" :key="f.id">
<span class="text-xs text-subtext0 bg-surface0 px-2 py-0.5 rounded">
<span x-text="f.homeTeam"></span> vs <span x-text="f.awayTeam"></span>
</span>
</template>
<span
x-show="getFixtureCount(week) > 3"
class="text-xs text-subtext1"
x-text="'+ ' + (getFixtureCount(week) - 3) + ' more'"
></span>
</div>
</div>
</div>
</template>
</div>
<!-- Add Game Week -->
<button
@click="addGameWeek()"
class="w-full mt-3 py-3 border-2 border-dashed border-surface1 rounded-lg
text-subtext0 hover:text-text hover:border-surface2
transition hover:cursor-pointer text-sm"
>
+ Add Game Week
</button>
</div>
<!-- Detail View -->
<div x-show="selectedGameWeek !== null">
<div class="flex items-center gap-3 mb-4">
<button
@click="backToList()"
class="text-blue hover:text-blue/80 transition hover:cursor-pointer text-sm"
>
&larr; Back
</button>
<h3 class="text-lg font-bold text-text" x-text="'Game Week ' + selectedGameWeek"></h3>
<span class="text-subtext0 text-sm">
(<span x-text="getFixtureCount(selectedGameWeek)"></span>
<span x-text="getFixtureCount(selectedGameWeek) === 1 ? 'fixture' : 'fixtures'"></span>)
</span>
<!-- Delete Week (only if empty) -->
<button
x-show="getFixtureCount(selectedGameWeek) === 0"
@click="deleteGameWeek(selectedGameWeek)"
class="ml-auto text-red hover:text-red/80 transition hover:cursor-pointer text-sm"
>
Delete Week
</button>
</div>
<!-- Drop Zone -->
<div
class="bg-mantle border-2 rounded-lg p-4 min-h-48 transition-colors"
:class="dropTarget === selectedGameWeek ? 'border-blue/50' : 'border-surface1'"
@dragover.prevent="dropTarget = selectedGameWeek"
@dragleave="if (dropTarget === selectedGameWeek) dropTarget = null"
@drop.prevent="onDrop(selectedGameWeek)"
>
<div x-show="getFixtureCount(selectedGameWeek) === 0" class="text-subtext1 text-sm italic text-center py-8">
Drop fixtures here
</div>
<div class="space-y-2">
<template x-for="fixture in getGameWeekFixtures(selectedGameWeek)" :key="fixture.id">
<div
draggable="true"
@dragstart="onDragStart($event, fixture)"
@dragend="onDragEnd()"
class="bg-surface0 border border-surface1 rounded-lg px-4 py-3
cursor-grab active:cursor-grabbing hover:bg-surface1 transition
flex items-center justify-between"
:class="{ 'opacity-50': draggedFixture && draggedFixture.id === fixture.id }"
>
<div class="flex items-center gap-3">
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded">
R<span x-text="fixture.round"></span>
</span>
<span class="text-text" x-text="fixture.homeTeam"></span>
<span class="text-subtext0 text-sm">vs</span>
<span class="text-text" x-text="fixture.awayTeam"></span>
</div>
<button
@click.stop="unallocateFixture(fixture)"
class="text-subtext0 hover:text-red transition hover:cursor-pointer text-xs"
title="Remove from game week"
>
&#10005;
</button>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- RIGHT PANEL: Unallocated Fixtures -->
<div class="bg-surface0 border border-surface1 rounded-lg p-4 min-h-96">
<h3 class="text-lg font-bold text-text mb-4">
Unallocated
<span class="text-subtext0 font-normal text-sm">
(<span x-text="unallocatedFixtures.length"></span>)
</span>
</h3>
<div
class="space-y-2 max-h-[600px] overflow-y-auto transition-colors rounded-lg p-1"
:class="dropTarget === 'unallocated' ? 'bg-surface1/30' : ''"
@dragover.prevent="dropTarget = 'unallocated'"
@dragleave="if (dropTarget === 'unallocated') dropTarget = null"
@drop.prevent="onDrop('unallocated')"
>
<div x-show="unallocatedFixtures.length === 0" class="text-subtext1 text-sm italic text-center py-8">
<span x-show="allFixtures.length === 0">No fixtures generated yet.</span>
<span x-show="allFixtures.length > 0">All fixtures allocated!</span>
</div>
<template x-for="fixture in unallocatedFixtures" :key="fixture.id">
<div
draggable="true"
@dragstart="onDragStart($event, fixture)"
@dragend="onDragEnd()"
class="bg-mantle border border-surface1 rounded-lg px-4 py-3
cursor-grab active:cursor-grabbing hover:bg-surface1 transition"
:class="{ 'opacity-50': draggedFixture && draggedFixture.id === fixture.id }"
>
<div class="flex items-center gap-3">
<span class="text-xs font-mono text-subtext0 bg-surface0 px-2 py-0.5 rounded">
R<span x-text="fixture.round"></span>
</span>
<span class="text-text" x-text="fixture.homeTeam"></span>
<span class="text-subtext0 text-sm">vs</span>
<span class="text-text" x-text="fixture.awayTeam"></span>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Alpine.js component -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('fixturesManager', (initialFixtures, seasonShortName, leagueShortName) => ({
allFixtures: initialFixtures || [],
seasonShortName: seasonShortName,
leagueShortName: leagueShortName,
// UI state
selectedGameWeek: null,
unsavedChanges: false,
isSaving: false,
isGenerating: false,
generateRounds: 1,
// Drag state
draggedFixture: null,
dropTarget: null,
// Computed
get unallocatedFixtures() {
return this.allFixtures
.filter(f => f.gameWeek === null)
.sort((a, b) => a.round - b.round || a.id - b.id);
},
get allGameWeekNumbers() {
const weeks = new Set();
for (const f of this.allFixtures) {
if (f.gameWeek !== null) {
weeks.add(f.gameWeek);
}
}
// Also include manually added empty weeks
for (const w of this._emptyWeeks || []) {
weeks.add(w);
}
return [...weeks].sort((a, b) => a - b);
},
// Track empty weeks that user created
_emptyWeeks: [],
getGameWeekFixtures(week) {
return this.allFixtures
.filter(f => f.gameWeek === week)
.sort((a, b) => a.round - b.round || a.id - b.id);
},
getFixtureCount(week) {
return this.allFixtures.filter(f => f.gameWeek === week).length;
},
getPreview(week) {
return this.getGameWeekFixtures(week).slice(0, 3);
},
// Game week management
addGameWeek() {
const existing = this.allGameWeekNumbers;
const next = existing.length > 0 ? Math.max(...existing) + 1 : 1;
this._emptyWeeks.push(next);
this.unsavedChanges = true;
},
deleteGameWeek(week) {
if (this.getFixtureCount(week) > 0) return;
this._emptyWeeks = this._emptyWeeks.filter(w => w !== week);
if (this.selectedGameWeek === week) {
this.selectedGameWeek = null;
}
this.unsavedChanges = true;
},
selectGameWeek(week) {
this.selectedGameWeek = week;
},
backToList() {
this.selectedGameWeek = null;
},
// Drag and drop
onDragStart(event, fixture) {
this.draggedFixture = fixture;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', fixture.id);
},
onDragEnd() {
this.draggedFixture = null;
this.dropTarget = null;
},
onDrop(target) {
if (!this.draggedFixture) return;
const fixture = this.allFixtures.find(f => f.id === this.draggedFixture.id);
if (!fixture) return;
if (target === 'unallocated') {
fixture.gameWeek = null;
} else {
fixture.gameWeek = target;
// Remove from empty weeks if it now has fixtures
this._emptyWeeks = this._emptyWeeks.filter(w => w !== target);
}
this.unsavedChanges = true;
this.draggedFixture = null;
this.dropTarget = null;
},
unallocateFixture(fixture) {
const f = this.allFixtures.find(ff => ff.id === fixture.id);
if (f) {
const oldWeek = f.gameWeek;
f.gameWeek = null;
// If the old week is now empty, track it
if (oldWeek !== null && this.getFixtureCount(oldWeek) === 0) {
this._emptyWeeks.push(oldWeek);
}
this.unsavedChanges = true;
}
},
// Validation
canSave() {
const weeks = this.allGameWeekNumbers;
if (weeks.length === 0) return false;
for (const week of weeks) {
if (this.getFixtureCount(week) === 0) return false;
}
return true;
},
// Server actions
generate() {
if (this.generateRounds < 1) return;
this.isGenerating = true;
const form = new FormData();
form.append('season_short_name', this.seasonShortName);
form.append('league_short_name', this.leagueShortName);
form.append('round', this.generateRounds);
htmx.ajax('POST', '/fixtures/generate', {
target: '#manage-fixtures-content',
swap: 'outerHTML',
values: Object.fromEntries(form)
}).finally(() => {
this.isGenerating = false;
});
},
save() {
if (!this.canSave()) return;
this.isSaving = true;
const form = new FormData();
form.append('season_short_name', this.seasonShortName);
form.append('league_short_name', this.leagueShortName);
this.allFixtures.forEach((f, i) => {
form.append('allocations[' + i + '][id]', f.id);
form.append('allocations[' + i + '][game_week]', f.gameWeek !== null ? f.gameWeek : 0);
});
fetch('/fixtures/update-game-weeks', {
method: 'POST',
body: form
}).then(response => {
if (response.ok) {
this.unsavedChanges = false;
}
this.isSaving = false;
}).catch(() => {
this.isSaving = false;
});
},
clearAll() {
const seasonShort = this.seasonShortName;
const leagueShort = this.leagueShortName;
window.dispatchEvent(new CustomEvent('confirm-action', {
detail: {
title: 'Clear All Fixtures',
message: 'This will delete all fixtures for this league. This action cannot be undone.',
action: () => {
htmx.ajax('DELETE',
'/seasons/' + seasonShort + '/leagues/' + leagueShort + '/fixtures',
{
target: '#manage-fixtures-content',
swap: 'outerHTML'
}
);
}
}
}));
},
}));
});
</script>
</div>
}