we have fixtures ladies and gentleman
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
282
internal/db/fixture.go
Normal file
282
internal/db/fixture.go
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
52
internal/db/migrations/20260215093841_add_fixtures.go
Normal file
52
internal/db/migrations/20260215093841_add_fixtures.go
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
173
internal/handlers/fixtures.go
Normal file
173
internal/handlers/fixtures.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">Coming Soon...</p>
|
||||
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">
|
||||
<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>
|
||||
}
|
||||
|
||||
477
internal/view/seasonsview/season_league_fixtures_manage.templ
Normal file
477
internal/view/seasonsview/season_league_fixtures_manage.templ
Normal 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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
}
|
||||
Reference in New Issue
Block a user