we have fixtures ladies and gentleman

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

View File

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

View File

@@ -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
View File

@@ -0,0 +1,282 @@
package db
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type Fixture struct {
bun.BaseModel `bun:"table:fixtures,alias:f"`
ID int `bun:"id,pk,autoincrement"`
SeasonID int `bun:",notnull,unique:round"`
LeagueID int `bun:",notnull,unique:round"`
HomeTeamID int `bun:",notnull,unique:round"`
AwayTeamID int `bun:",notnull,unique:round"`
Round int `bun:"round,unique:round"`
GameWeek *int `bun:"game_week"`
CreatedAt int64 `bun:"created_at,notnull"`
UpdatedAt *int64 `bun:"updated_at"`
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
League *League `bun:"rel:belongs-to,join:league_id=id"`
HomeTeam *Team `bun:"rel:belongs-to,join:home_team_id=id"`
AwayTeam *Team `bun:"rel:belongs-to,join:away_team_id=id"`
}
func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
homeTeamID, awayTeamID, round int, audit *AuditMeta,
) (*Fixture, error) {
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return nil, errors.Wrap(err, "GetSeasonLeague")
}
homeTeam, err := GetTeam(ctx, tx, homeTeamID)
if err != nil {
return nil, errors.Wrap(err, "GetTeam")
}
awayTeam, err := GetTeam(ctx, tx, awayTeamID)
if err != nil {
return nil, errors.Wrap(err, "GetTeam")
}
if err = checkTeamsAssociated(season, league, teams, []*Team{homeTeam, awayTeam}); err != nil {
return nil, errors.Wrap(err, "checkTeamsAssociated")
}
fixture := newFixture(season, league, homeTeam, awayTeam, round, time.Now())
err = Insert(tx, fixture).WithAudit(audit, nil).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert")
}
return fixture, nil
}
func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
round int, audit *AuditMeta,
) ([]*Fixture, error) {
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return nil, errors.Wrap(err, "GetSeasonLeague")
}
fixtures := generateRound(season, league, round, teams)
err = InsertMultiple(tx, fixtures).WithAudit(audit, nil).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "InsertMultiple")
}
return fixtures, nil
}
func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Fixture, error) {
season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeasonLeague")
}
fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", season.ID).
Where("league_id = ?", league.ID).
Order("game_week ASC NULLS FIRST", "round ASC", "id ASC").
Relation("HomeTeam").
Relation("AwayTeam").
GetAll(ctx)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetList")
}
return season, league, fixtures, nil
}
func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) {
return GetByID[Fixture](tx, id).
Relation("Season").
Relation("League").
Relation("HomeTeam").
Relation("AwayTeam").
Get(ctx)
}
func GetFixturesByGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID, gameweek int) ([]*Fixture, error) {
fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("game_week = ?", gameweek).
Order("round ASC", "id ASC").
Relation("HomeTeam").
Relation("AwayTeam").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
return fixtures, nil
}
func GetUnallocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Fixture, error) {
fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("game_week IS NULL").
Order("round ASC", "id ASC").
Relation("HomeTeam").
Relation("AwayTeam").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
return fixtures, nil
}
func CountUnallocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) (int, error) {
count, err := GetList[Fixture](tx).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("game_week IS NULL").
Count(ctx)
if err != nil {
return 0, errors.Wrap(err, "GetList")
}
return count, nil
}
func GetMaxGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID int) (int, error) {
var maxGameWeek int
err := tx.NewSelect().
Model((*Fixture)(nil)).
Column("game_week").
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Order("game_week DESC NULLS LAST").
Limit(1).Scan(ctx, &maxGameWeek)
if err != nil {
return 0, errors.Wrap(err, "tx.NewSelect")
}
return maxGameWeek, nil
}
func UpdateFixtureGameWeeks(ctx context.Context, tx bun.Tx, fixtures []*Fixture, audit *AuditMeta) error {
details := []any{}
for _, fixture := range fixtures {
err := UpdateByID(tx, fixture.ID, fixture).
Column("game_week").
Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
details = append(details, map[string]any{"fixture_id": fixture.ID, "game_week": fixture.GameWeek})
}
info := &AuditInfo{
"fixtures.manage",
"fixture",
"multiple",
map[string]any{"updated": details},
}
err := LogSuccess(ctx, tx, audit, info)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
return nil
}
func DeleteAllFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, audit *AuditMeta) error {
season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return errors.Wrap(err, "GetSeasonLeague")
}
err = DeleteItem[Fixture](tx).
Where("season_id = ?", season.ID).
Where("league_id = ?", league.ID).
WithAudit(audit, nil).
Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteItem")
}
return nil
}
func DeleteFixture(ctx context.Context, tx bun.Tx, id int, audit *AuditMeta) error {
err := DeleteByID[Fixture](tx, id).
WithAudit(audit, nil).
Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteByID")
}
return nil
}
func newFixture(season *Season, league *League, homeTeam, awayTeam *Team, round int, created time.Time) *Fixture {
return &Fixture{
SeasonID: season.ID,
LeagueID: league.ID,
HomeTeamID: homeTeam.ID,
AwayTeamID: awayTeam.ID,
Round: round,
CreatedAt: created.Unix(),
}
}
func checkTeamsAssociated(season *Season, league *League, teamsIn []*Team, toCheck []*Team) error {
badIDs := []string{}
master := map[int]bool{}
for _, team := range teamsIn {
master[team.ID] = true
}
for _, team := range toCheck {
if !master[team.ID] {
badIDs = append(badIDs, strconv.Itoa(team.ID))
}
}
ids := strings.Join(badIDs, ",")
if len(ids) > 0 {
return BadRequestNotAssociated("season_league", "team",
"season_id,league_id", "ids",
fmt.Sprintf("%v,%v", season.ID, league.ID),
ids)
}
return nil
}
type versus struct {
homeTeam *Team
awayTeam *Team
}
func generateRound(season *Season, league *League, round int, teams []*Team) []*Fixture {
now := time.Now()
numTeams := len(teams)
numGames := numTeams * (numTeams - 1) / 2
fixtures := make([]*Fixture, numGames)
for i, matchup := range allTeamsPlay(teams, round) {
fixtures[i] = newFixture(season, league, matchup.homeTeam, matchup.awayTeam, round, now)
}
return fixtures
}
func allTeamsPlay(teams []*Team, round int) []*versus {
matchups := []*versus{}
if len(teams) < 2 {
return matchups
}
team1 := teams[0]
teams = teams[1:]
matchups = append(matchups, playOtherTeams(team1, teams, round)...)
matchups = append(matchups, allTeamsPlay(teams, round)...)
return matchups
}
func playOtherTeams(team *Team, teams []*Team, round int) []*versus {
matchups := make([]*versus, len(teams))
for i, opponent := range teams {
versus := &versus{}
if i%2+round%2 == 0 {
versus.homeTeam = team
versus.awayTeam = opponent
} else {
versus.homeTeam = opponent
versus.awayTeam = team
}
matchups[i] = versus
}
return matchups
}

View File

@@ -92,6 +92,11 @@ func (l *listgetter[T]) Where(query string, args ...any) *listgetter[T] {
return l
}
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) {

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
package migrations
import (
"context"
"time"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, conn *bun.DB) error {
// Add your migration code here
_, err := conn.NewCreateTable().
Model((*db.Fixture)(nil)).
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
now := time.Now().Unix()
permissionsData := []*db.Permission{
{Name: "fixtures.create", DisplayName: "Create Fixtures", Description: "Create new fixtures", Resource: "fixtures", Action: "create", IsSystem: true, CreatedAt: now},
{Name: "fixtures.manage", DisplayName: "Manage Fixtures", Description: "Manage fixtures", Resource: "fixtures", Action: "manage", IsSystem: true, CreatedAt: now},
{Name: "fixtures.delete", DisplayName: "Delete Fixtures", Description: "Delete fixtures", Resource: "fixtures", Action: "delete", IsSystem: true, CreatedAt: now},
}
_, err = conn.NewInsert().
Model(&permissionsData).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "conn.NewInsert")
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here
_, err := conn.NewDropTable().
Model((*db.Fixture)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
)
}

View File

@@ -28,17 +28,17 @@ const (
type Season struct {
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)
}

View File

@@ -24,19 +24,14 @@ func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShor
return nil, nil, nil, errors.New("league short_name cannot be empty")
}
// 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
}

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,173 @@
package handlers
import (
"context"
"net/http"
"strconv"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/validation"
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
func GenerateFixtures(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
seasonShortName := getter.String("season_short_name").TrimSpace().Required().Value
leagueShortName := getter.String("league_short_name").TrimSpace().Required().Value
round := getter.Int("round").Required().Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
var season *db.Season
var league *db.League
var fixtures []*db.Fixture
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
_, err := db.NewRound(ctx, tx, seasonShortName, leagueShortName, round, db.NewAudit(r, nil))
if err != nil {
if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))
return false, nil
}
return false, errors.Wrap(err, "db.NewRound")
}
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtures")
}
return true, nil
}); !ok {
return
}
renderSafely(seasonsview.SeasonLeagueManageFixtures(season, league, fixtures), s, r, w)
})
}
func UpdateFixtures(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
seasonShortName := getter.String("season_short_name").TrimSpace().Required().Value
leagueShortName := getter.String("league_short_name").TrimSpace().Required().Value
allocations := getter.GetMaps("allocations")
if !getter.ValidateAndNotify(s, w, r) {
return
}
updates, err := mapUpdates(allocations)
if err != nil {
respond.BadRequest(w, errors.Wrap(err, "strconv.Atoi"))
return
}
var fixtures []*db.Fixture
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
_, _, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixtures")
}
var valid bool
fixtures, valid = updateFixtures(fixtures, updates)
if !valid {
notify.Warn(s, w, r, "Invalid game weeks", "A game week is missing or has no games", nil)
return false, nil
}
err = db.UpdateFixtureGameWeeks(ctx, tx, fixtures, db.NewAudit(r, nil))
if err != nil {
if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.UpdateFixtureGameWeeks"))
}
return false, errors.Wrap(err, "db.UpdateFixtureGameWeeks")
}
return true, nil
}) {
return
}
notify.Success(s, w, r, "Fixtures Updated", "Fixtures successfully updated", nil)
})
}
func DeleteFixture(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureIDstr := r.PathValue("fixture_id")
fixtureID, err := strconv.Atoi(fixtureIDstr)
if err != nil {
respond.BadRequest(w, errors.Wrap(err, "strconv.Atoi"))
return
}
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
err := db.DeleteFixture(ctx, tx, fixtureID, db.NewAudit(r, nil))
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.DeleteFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.DeleteFixture")
}
return true, nil
}) {
return
}
})
}
func mapUpdates(allocations []map[string]string) (map[int]int, error) {
updates := map[int]int{}
for _, v := range allocations {
id, err := strconv.Atoi(v["id"])
if err != nil {
return nil, errors.Wrap(err, "strconv.Atoi")
}
gameWeek, err := strconv.Atoi(v["game_week"])
if err != nil {
return nil, errors.Wrap(err, "strconv.Atoi")
}
updates[id] = gameWeek
}
return updates, nil
}
func updateFixtures(fixtures []*db.Fixture, updates map[int]int) ([]*db.Fixture, bool) {
updated := []*db.Fixture{}
gameWeeks := map[int]int{}
for _, fixture := range fixtures {
if gameWeek, exists := updates[fixture.ID]; exists {
fixture.GameWeek = &gameWeek
updated = append(updated, fixture)
}
gameWeeks[*fixture.GameWeek]++
}
for i := range len(gameWeeks) {
count, exists := gameWeeks[i+1]
if !exists || count < 1 {
return nil, false
}
}
return updated, true
}

View File

@@ -6,8 +6,9 @@ import (
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/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)
})
}

View File

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

View File

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

View File

@@ -160,6 +160,34 @@ func addRoutes(
},
}
fixturesRoutes := []hws.Route{
{
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/fixtures/manage",
Method: hws.MethodGET,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.SeasonLeagueManageFixturesPage(s, conn)),
},
{
Path: "/fixtures/generate",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FixturesCreate)(handlers.GenerateFixtures(s, conn)),
},
{
Path: "/fixtures/update-game-weeks",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.UpdateFixtures(s, conn)),
},
{
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/fixtures",
Method: hws.MethodDELETE,
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.SeasonLeagueDeleteFixtures(s, conn)),
},
{
Path: "/fixtures/{fixture_id}",
Method: hws.MethodDELETE,
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)),
},
}
teamRoutes := []hws.Route{
{
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

View File

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

View File

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

View File

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