we have fixtures ladies and gentleman
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
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
|
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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
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 {
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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/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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
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