diff --git a/internal/db/fixture.go b/internal/db/fixture.go index 37987a6..222bab0 100644 --- a/internal/db/fixture.go +++ b/internal/db/fixture.go @@ -28,6 +28,35 @@ type Fixture struct { 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"` + + Schedules []*FixtureSchedule `bun:"rel:has-many,join:id=fixture_id"` +} + +// CanSchedule checks if the user is a manager of one of the teams in the fixture. +// Returns (canSchedule, teamID) where teamID is the team the user manages (0 if not a manager). +func (f *Fixture) CanSchedule(ctx context.Context, tx bun.Tx, user *User) (bool, int, error) { + if user == nil || user.Player == nil { + return false, 0, nil + } + roster := new(TeamRoster) + err := tx.NewSelect(). + Model(roster). + Column("team_id", "is_manager"). + Where("team_id IN (?)", bun.In([]int{f.HomeTeamID, f.AwayTeamID})). + Where("season_id = ?", f.SeasonID). + Where("league_id = ?", f.LeagueID). + Where("player_id = ?", user.Player.ID). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return false, 0, nil + } + return false, 0, errors.Wrap(err, "tx.NewSelect") + } + if !roster.IsManager { + return false, 0, nil + } + return true, roster.TeamID, nil } func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, diff --git a/internal/db/fixture_schedule.go b/internal/db/fixture_schedule.go new file mode 100644 index 0000000..6d4ec13 --- /dev/null +++ b/internal/db/fixture_schedule.go @@ -0,0 +1,426 @@ +package db + +import ( + "context" + "time" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// ScheduleStatus represents the current status of a fixture schedule proposal +type ScheduleStatus string + +const ( + ScheduleStatusPending ScheduleStatus = "pending" + ScheduleStatusAccepted ScheduleStatus = "accepted" + ScheduleStatusRejected ScheduleStatus = "rejected" + ScheduleStatusRescheduled ScheduleStatus = "rescheduled" + ScheduleStatusPostponed ScheduleStatus = "postponed" + ScheduleStatusCancelled ScheduleStatus = "cancelled" + ScheduleStatusWithdrawn ScheduleStatus = "withdrawn" +) + +// IsTerminal returns true if the status is a terminal (immutable) state +func (s ScheduleStatus) IsTerminal() bool { + switch s { + case ScheduleStatusRejected, ScheduleStatusRescheduled, + ScheduleStatusPostponed, ScheduleStatusCancelled, + ScheduleStatusWithdrawn: + return true + } + return false +} + +// RescheduleReason represents the predefined reasons for rescheduling or postponing +type RescheduleReason string + +const ( + ReasonMutuallyAgreed RescheduleReason = "Mutually Agreed" + ReasonTeamUnavailable RescheduleReason = "Team Unavailable" + ReasonTeamNoShow RescheduleReason = "Team No-show" +) + +type FixtureSchedule struct { + bun.BaseModel `bun:"table:fixture_schedules,alias:fs"` + + ID int `bun:"id,pk,autoincrement"` + FixtureID int `bun:",notnull"` + ScheduledTime *time.Time `bun:"scheduled_time"` + ProposedByTeamID int `bun:",notnull"` + AcceptedByTeamID *int `bun:"accepted_by_team_id"` + Status ScheduleStatus `bun:",notnull,default:'pending'"` + RescheduleReason *string `bun:"reschedule_reason"` + CreatedAt int64 `bun:",notnull"` + UpdatedAt *int64 `bun:"updated_at"` + + Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"` + ProposedBy *Team `bun:"rel:belongs-to,join:proposed_by_team_id=id"` + AcceptedBy *Team `bun:"rel:belongs-to,join:accepted_by_team_id=id"` +} + +// GetAcceptedSchedulesForFixtures returns the accepted schedule for each fixture in the given list. +// Returns a map of fixtureID -> *FixtureSchedule (only accepted schedules are included). +func GetAcceptedSchedulesForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs []int) (map[int]*FixtureSchedule, error) { + if len(fixtureIDs) == 0 { + return map[int]*FixtureSchedule{}, nil + } + schedules, err := GetList[FixtureSchedule](tx). + Where("fixture_id IN (?)", bun.In(fixtureIDs)). + Where("status = ?", ScheduleStatusAccepted). + GetAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "GetList") + } + result := make(map[int]*FixtureSchedule, len(schedules)) + for _, s := range schedules { + // If multiple accepted exist (shouldn't happen), keep the most recent + existing, ok := result[s.FixtureID] + if !ok || s.CreatedAt > existing.CreatedAt { + result[s.FixtureID] = s + } + } + return result, nil +} + +// GetFixtureScheduleHistory returns all schedule records for a fixture in chronological order +func GetFixtureScheduleHistory(ctx context.Context, tx bun.Tx, fixtureID int) ([]*FixtureSchedule, error) { + schedules, err := GetList[FixtureSchedule](tx). + Where("fixture_id = ?", fixtureID). + Order("created_at ASC", "id ASC"). + Relation("ProposedBy"). + Relation("AcceptedBy"). + GetAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "GetList") + } + return schedules, nil +} + +// GetCurrentFixtureSchedule returns the most recent schedule record for a fixture. +// Returns nil, nil if no schedule exists. +func GetCurrentFixtureSchedule(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureSchedule, error) { + schedule := new(FixtureSchedule) + err := tx.NewSelect(). + Model(schedule). + Where("fixture_id = ?", fixtureID). + Order("created_at DESC", "id DESC"). + Relation("ProposedBy"). + Relation("AcceptedBy"). + Limit(1). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil, nil + } + return nil, errors.Wrap(err, "tx.NewSelect") + } + return schedule, nil +} + +// ProposeFixtureSchedule creates a new pending schedule proposal for a fixture. +// If there is an existing pending record with no time (postponed placeholder), it will be +// superseded. Cannot propose on cancelled or accepted schedules. +func ProposeFixtureSchedule( + ctx context.Context, + tx bun.Tx, + fixtureID, proposedByTeamID int, + scheduledTime time.Time, + audit *AuditMeta, +) (*FixtureSchedule, error) { + current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID) + if err != nil { + return nil, errors.Wrap(err, "GetCurrentFixtureSchedule") + } + if current != nil { + switch current.Status { + case ScheduleStatusCancelled: + return nil, BadRequest("cannot propose a new time for a cancelled fixture") + case ScheduleStatusAccepted: + return nil, BadRequest("fixture already has an accepted schedule; use reschedule instead") + case ScheduleStatusPending: + // Supersede existing pending record (e.g., postponed placeholder or old proposal) + now := time.Now().Unix() + current.Status = ScheduleStatusRescheduled + current.UpdatedAt = &now + err = UpdateByID(tx, current.ID, current). + Column("status", "updated_at"). + Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "UpdateByID") + } + // rejected, rescheduled, postponed are terminal β€” safe to create a new proposal + } + } + + schedule := &FixtureSchedule{ + FixtureID: fixtureID, + ScheduledTime: &scheduledTime, + ProposedByTeamID: proposedByTeamID, + Status: ScheduleStatusPending, + CreatedAt: time.Now().Unix(), + } + err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.propose", + ResourceType: "fixture_schedule", + ResourceID: fixtureID, + Details: map[string]any{ + "fixture_id": fixtureID, + "proposed_by": proposedByTeamID, + "scheduled_time": scheduledTime, + }, + }).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "Insert") + } + return schedule, nil +} + +// AcceptFixtureSchedule accepts a pending schedule proposal. +// The acceptedByTeamID must be the other team (not the proposer). +func AcceptFixtureSchedule( + ctx context.Context, + tx bun.Tx, + scheduleID, acceptedByTeamID int, + audit *AuditMeta, +) error { + schedule, err := GetByID[FixtureSchedule](tx, scheduleID).Get(ctx) + if err != nil { + return errors.Wrap(err, "GetByID") + } + if schedule.Status != ScheduleStatusPending { + return BadRequest("schedule is not in pending status") + } + if schedule.ProposedByTeamID == acceptedByTeamID { + return BadRequest("cannot accept your own proposal") + } + + now := time.Now().Unix() + schedule.AcceptedByTeamID = &acceptedByTeamID + schedule.Status = ScheduleStatusAccepted + schedule.UpdatedAt = &now + err = UpdateByID(tx, schedule.ID, schedule). + Column("accepted_by_team_id", "status", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.accept", + ResourceType: "fixture_schedule", + ResourceID: scheduleID, + Details: map[string]any{ + "fixture_id": schedule.FixtureID, + "accepted_by": acceptedByTeamID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// RejectFixtureSchedule rejects a pending schedule proposal. +func RejectFixtureSchedule( + ctx context.Context, + tx bun.Tx, + scheduleID int, + audit *AuditMeta, +) error { + schedule, err := GetByID[FixtureSchedule](tx, scheduleID).Get(ctx) + if err != nil { + return errors.Wrap(err, "GetByID") + } + if schedule.Status != ScheduleStatusPending { + return BadRequest("schedule is not in pending status") + } + + now := time.Now().Unix() + schedule.Status = ScheduleStatusRejected + schedule.UpdatedAt = &now + err = UpdateByID(tx, schedule.ID, schedule). + Column("status", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.reject", + ResourceType: "fixture_schedule", + ResourceID: scheduleID, + Details: map[string]any{ + "fixture_id": schedule.FixtureID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// RescheduleFixtureSchedule marks the current accepted schedule as rescheduled and creates +// a new pending proposal with the new time. +func RescheduleFixtureSchedule( + ctx context.Context, + tx bun.Tx, + fixtureID, proposedByTeamID int, + newTime time.Time, + reason string, + audit *AuditMeta, +) (*FixtureSchedule, error) { + current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID) + if err != nil { + return nil, errors.Wrap(err, "GetCurrentFixtureSchedule") + } + if current == nil || current.Status != ScheduleStatusAccepted { + return nil, BadRequest("no accepted schedule to reschedule") + } + + now := time.Now().Unix() + current.Status = ScheduleStatusRescheduled + current.RescheduleReason = &reason + current.UpdatedAt = &now + err = UpdateByID(tx, current.ID, current). + Column("status", "reschedule_reason", "updated_at"). + Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "UpdateByID") + } + + // Create new pending proposal + schedule := &FixtureSchedule{ + FixtureID: fixtureID, + ScheduledTime: &newTime, + ProposedByTeamID: proposedByTeamID, + Status: ScheduleStatusPending, + CreatedAt: time.Now().Unix(), + } + err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.reschedule", + ResourceType: "fixture_schedule", + ResourceID: fixtureID, + Details: map[string]any{ + "fixture_id": fixtureID, + "proposed_by": proposedByTeamID, + "new_time": newTime, + "reason": reason, + "old_schedule_id": current.ID, + }, + }).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "Insert") + } + return schedule, nil +} + +// PostponeFixtureSchedule marks the current accepted schedule as postponed. +// This is a terminal state β€” a new proposal can be created afterwards. +func PostponeFixtureSchedule( + ctx context.Context, + tx bun.Tx, + fixtureID int, + reason string, + audit *AuditMeta, +) error { + current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID) + if err != nil { + return errors.Wrap(err, "GetCurrentFixtureSchedule") + } + if current == nil || current.Status != ScheduleStatusAccepted { + return BadRequest("no accepted schedule to postpone") + } + + now := time.Now().Unix() + current.Status = ScheduleStatusPostponed + current.RescheduleReason = &reason + current.UpdatedAt = &now + err = UpdateByID(tx, current.ID, current). + Column("status", "reschedule_reason", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.postpone", + ResourceType: "fixture_schedule", + ResourceID: fixtureID, + Details: map[string]any{ + "fixture_id": fixtureID, + "reason": reason, + "old_schedule_id": current.ID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// WithdrawFixtureSchedule allows the proposer to withdraw their pending proposal. +// Only the team that proposed can withdraw it. +func WithdrawFixtureSchedule( + ctx context.Context, + tx bun.Tx, + scheduleID, withdrawByTeamID int, + audit *AuditMeta, +) error { + schedule, err := GetByID[FixtureSchedule](tx, scheduleID).Get(ctx) + if err != nil { + return errors.Wrap(err, "GetByID") + } + if schedule.Status != ScheduleStatusPending { + return BadRequest("schedule is not in pending status") + } + if schedule.ProposedByTeamID != withdrawByTeamID { + return BadRequest("only the proposing team can withdraw their proposal") + } + + now := time.Now().Unix() + schedule.Status = ScheduleStatusWithdrawn + schedule.UpdatedAt = &now + err = UpdateByID(tx, schedule.ID, schedule). + Column("status", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.withdraw", + ResourceType: "fixture_schedule", + ResourceID: scheduleID, + Details: map[string]any{ + "fixture_id": schedule.FixtureID, + "withdrawn_by": withdrawByTeamID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// CancelFixtureSchedule marks the current schedule as cancelled. This is a terminal state. +// Requires fixtures.manage permission (moderator-level). +func CancelFixtureSchedule( + ctx context.Context, + tx bun.Tx, + fixtureID int, + reason string, + audit *AuditMeta, +) error { + current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID) + if err != nil { + return errors.Wrap(err, "GetCurrentFixtureSchedule") + } + if current == nil { + return BadRequest("no schedule to cancel") + } + if current.Status.IsTerminal() { + return BadRequest("schedule is already in a terminal state") + } + + now := time.Now().Unix() + current.Status = ScheduleStatusCancelled + current.RescheduleReason = &reason + current.UpdatedAt = &now + err = UpdateByID(tx, current.ID, current). + Column("status", "reschedule_reason", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.cancel", + ResourceType: "fixture_schedule", + ResourceID: fixtureID, + Details: map[string]any{ + "fixture_id": fixtureID, + "reason": reason, + "schedule_id": current.ID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} diff --git a/internal/db/migrations/20260221103653_fixture_schedules.go b/internal/db/migrations/20260221103653_fixture_schedules.go new file mode 100644 index 0000000..c48dbb7 --- /dev/null +++ b/internal/db/migrations/20260221103653_fixture_schedules.go @@ -0,0 +1,58 @@ +package migrations + +import ( + "context" + + "git.haelnorr.com/h/oslstats/internal/db" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister( + // UP migration + func(ctx context.Context, conn *bun.DB) error { + _, err := conn.NewCreateTable(). + Model((*db.FixtureSchedule)(nil)). + IfNotExists(). + ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`). + ForeignKey(`("proposed_by_team_id") REFERENCES "teams" ("id")`). + ForeignKey(`("accepted_by_team_id") REFERENCES "teams" ("id")`). + Exec(ctx) + if err != nil { + return err + } + + // Create index on fixture_id for faster lookups + _, err = conn.NewCreateIndex(). + Model((*db.FixtureSchedule)(nil)). + Index("idx_fixture_schedules_fixture_id"). + Column("fixture_id"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Create index on status for filtering + _, err = conn.NewCreateIndex(). + Model((*db.FixtureSchedule)(nil)). + Index("idx_fixture_schedules_status"). + Column("status"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + return nil + }, + // DOWN migration + func(ctx context.Context, conn *bun.DB) error { + _, err := conn.NewDropTable(). + Model((*db.FixtureSchedule)(nil)). + IfExists(). + Exec(ctx) + return err + }, + ) +} diff --git a/internal/db/player.go b/internal/db/player.go index b256dd8..27871cc 100644 --- a/internal/db/player.go +++ b/internal/db/player.go @@ -19,6 +19,13 @@ type Player struct { User *User `bun:"rel:belongs-to,join:user_id=id" json:"-"` } +func (p *Player) DisplayName() string { + if p.User != nil { + return p.User.Username + } + return p.Name +} + // NewPlayer creates a new player in the database. If there is an existing user with the same // discordID, it will automatically link that user to the player func NewPlayer(ctx context.Context, tx bun.Tx, discordID string, audit *AuditMeta) (*Player, error) { diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 876577f..fbfcd5d 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -9,6 +9,7 @@ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; --spacing: 0.25rem; + --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; --breakpoint-2xl: 96rem; --container-sm: 24rem; @@ -555,6 +556,9 @@ .max-w-screen-2xl { max-width: var(--breakpoint-2xl); } + .max-w-screen-lg { + max-width: var(--breakpoint-lg); + } .max-w-screen-xl { max-width: var(--breakpoint-xl); } @@ -828,6 +832,12 @@ .border-blue { border-color: var(--blue); } + .border-blue\/30 { + border-color: var(--blue); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--blue) 30%, transparent); + } + } .border-blue\/50 { border-color: var(--blue); @supports (color: color-mix(in lab, red, red)) { @@ -888,6 +898,12 @@ .bg-blue { background-color: var(--blue); } + .bg-blue\/5 { + background-color: var(--blue); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--blue) 5%, transparent); + } + } .bg-blue\/20 { background-color: var(--blue); @supports (color: color-mix(in lab, red, red)) { @@ -936,6 +952,12 @@ .bg-peach { background-color: var(--peach); } + .bg-peach\/20 { + background-color: var(--peach); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--peach) 20%, transparent); + } + } .bg-red { background-color: var(--red); } @@ -996,6 +1018,9 @@ .p-2\.5 { padding: calc(var(--spacing) * 2.5); } + .p-3 { + padding: calc(var(--spacing) * 3); + } .p-4 { padding: calc(var(--spacing) * 4); } @@ -1183,9 +1208,18 @@ .text-overlay0 { color: var(--overlay0); } + .text-peach { + color: var(--peach); + } .text-red { color: var(--red); } + .text-red\/80 { + color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--red) 80%, transparent); + } + } .text-subtext0 { color: var(--subtext0); } @@ -1384,6 +1418,16 @@ } } } + .hover\:bg-peach\/80 { + &:hover { + @media (hover: hover) { + background-color: var(--peach); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--peach) 80%, transparent); + } + } + } + } .hover\:bg-red\/25 { &:hover { @media (hover: hover) { @@ -1479,6 +1523,16 @@ } } } + .hover\:bg-yellow\/80 { + &:hover { + @media (hover: hover) { + background-color: var(--yellow); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--yellow) 80%, transparent); + } + } + } + } .hover\:text-blue { &:hover { @media (hover: hover) { diff --git a/internal/handlers/fixture_detail.go b/internal/handlers/fixture_detail.go new file mode 100644 index 0000000..852c224 --- /dev/null +++ b/internal/handlers/fixture_detail.go @@ -0,0 +1,462 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + "time" + + "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/throw" + "git.haelnorr.com/h/oslstats/internal/validation" + "git.haelnorr.com/h/oslstats/internal/view/seasonsview" + "git.haelnorr.com/h/timefmt" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// FixtureDetailPage renders the fixture detail page with scheduling UI and history +func FixtureDetailPage( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid fixture ID", err) + return + } + + var fixture *db.Fixture + var currentSchedule *db.FixtureSchedule + var history []*db.FixtureSchedule + var canSchedule bool + var userTeamID int + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + fixture, err = db.GetFixture(ctx, tx, fixtureID) + if err != nil { + if db.IsBadRequest(err) { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return false, errors.Wrap(err, "db.GetFixture") + } + currentSchedule, err = db.GetCurrentFixtureSchedule(ctx, tx, fixtureID) + if err != nil { + return false, errors.Wrap(err, "db.GetCurrentFixtureSchedule") + } + history, err = db.GetFixtureScheduleHistory(ctx, tx, fixtureID) + if err != nil { + return false, errors.Wrap(err, "db.GetFixtureScheduleHistory") + } + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err = fixture.CanSchedule(ctx, tx, user) + if err != nil { + return false, errors.Wrap(err, "fixture.CanSchedule") + } + return true, nil + }); !ok { + return + } + + renderSafely(seasonsview.FixtureDetailPage( + fixture, currentSchedule, history, canSchedule, userTeamID, + ), s, r, w) + }) +} + +// ProposeSchedule handles POST /fixtures/{fixture_id}/schedule +func ProposeSchedule( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid fixture ID", err) + return + } + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash(). + DayNumeric2().T().Hour24().Colon().Minute().Build() + scheduledTime := getter.Time("scheduled_time", format).After(time.Now()).Value + + if !getter.ValidateAndNotify(s, w, r) { + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + fixture, err := db.GetFixture(ctx, tx, fixtureID) + if err != nil { + if db.IsBadRequest(err) { + respond.NotFound(w, errors.Wrap(err, "db.GetFixture")) + return false, nil + } + return false, errors.Wrap(err, "db.GetFixture") + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user) + if err != nil { + return false, errors.Wrap(err, "fixture.CanSchedule") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to propose a schedule", nil) + return false, nil + } + + _, err = db.ProposeFixtureSchedule(ctx, tx, fixtureID, userTeamID, scheduledTime, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Propose", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.ProposeFixtureSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil) + respond.HXRedirect(w, "/fixtures/%d", fixtureID) + }) +} + +// AcceptSchedule handles POST /fixtures/{fixture_id}/schedule/{schedule_id}/accept +func AcceptSchedule( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid fixture ID", err) + return + } + scheduleID, err := strconv.Atoi(r.PathValue("schedule_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid schedule ID", err) + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + fixture, err := db.GetFixture(ctx, tx, fixtureID) + if err != nil { + if db.IsBadRequest(err) { + respond.NotFound(w, errors.Wrap(err, "db.GetFixture")) + return false, nil + } + return false, errors.Wrap(err, "db.GetFixture") + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user) + if err != nil { + return false, errors.Wrap(err, "fixture.CanSchedule") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to accept a schedule", nil) + return false, nil + } + + err = db.AcceptFixtureSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Accept", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.AcceptFixtureSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Schedule Accepted", "The fixture time has been confirmed.", nil) + respond.HXRedirect(w, "/fixtures/%d", fixtureID) + }) +} + +// RejectSchedule handles POST /fixtures/{fixture_id}/schedule/{schedule_id}/reject +func RejectSchedule( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid fixture ID", err) + return + } + scheduleID, err := strconv.Atoi(r.PathValue("schedule_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid schedule ID", err) + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + fixture, err := db.GetFixture(ctx, tx, fixtureID) + if err != nil { + if db.IsBadRequest(err) { + respond.NotFound(w, errors.Wrap(err, "db.GetFixture")) + return false, nil + } + return false, errors.Wrap(err, "db.GetFixture") + } + + user := db.CurrentUser(ctx) + canSchedule, _, err := fixture.CanSchedule(ctx, tx, user) + if err != nil { + return false, errors.Wrap(err, "fixture.CanSchedule") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to reject a schedule", nil) + return false, nil + } + + err = db.RejectFixtureSchedule(ctx, tx, scheduleID, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Reject", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.RejectFixtureSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Schedule Rejected", "The proposed time has been rejected.", nil) + respond.HXRedirect(w, "/fixtures/%d", fixtureID) + }) +} + +// PostponeSchedule handles POST /fixtures/{fixture_id}/schedule/postpone +func PostponeSchedule( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid fixture ID", err) + return + } + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + reason := getter.String("reschedule_reason").TrimSpace().Required().Value + if !getter.ValidateAndNotify(s, w, r) { + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + fixture, err := db.GetFixture(ctx, tx, fixtureID) + if err != nil { + if db.IsBadRequest(err) { + respond.NotFound(w, errors.Wrap(err, "db.GetFixture")) + return false, nil + } + return false, errors.Wrap(err, "db.GetFixture") + } + + user := db.CurrentUser(ctx) + canSchedule, _, err := fixture.CanSchedule(ctx, tx, user) + if err != nil { + return false, errors.Wrap(err, "fixture.CanSchedule") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to postpone a fixture", nil) + return false, nil + } + + err = db.PostponeFixtureSchedule(ctx, tx, fixtureID, reason, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Postpone", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.PostponeFixtureSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Fixture Postponed", "The fixture has been postponed.", nil) + respond.HXRedirect(w, "/fixtures/%d", fixtureID) + }) +} + +// RescheduleFixture handles POST /fixtures/{fixture_id}/schedule/reschedule +func RescheduleFixture( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid fixture ID", err) + return + } + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash(). + DayNumeric2().T().Hour24().Colon().Minute().Build() + scheduledTime := getter.Time("scheduled_time", format).After(time.Now()).Value + reason := getter.String("reschedule_reason").TrimSpace().Required().Value + + if !getter.ValidateAndNotify(s, w, r) { + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + fixture, err := db.GetFixture(ctx, tx, fixtureID) + if err != nil { + if db.IsBadRequest(err) { + respond.NotFound(w, errors.Wrap(err, "db.GetFixture")) + return false, nil + } + return false, errors.Wrap(err, "db.GetFixture") + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user) + if err != nil { + return false, errors.Wrap(err, "fixture.CanSchedule") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to reschedule a fixture", nil) + return false, nil + } + + _, err = db.RescheduleFixtureSchedule(ctx, tx, fixtureID, userTeamID, scheduledTime, reason, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Reschedule", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.RescheduleFixtureSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Fixture Rescheduled", "The new proposed time has been submitted.", nil) + respond.HXRedirect(w, "/fixtures/%d", fixtureID) + }) +} + +// WithdrawSchedule handles POST /fixtures/{fixture_id}/schedule/{schedule_id}/withdraw +// Only the proposing team manager can withdraw their own pending proposal. +func WithdrawSchedule( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid fixture ID", err) + return + } + scheduleID, err := strconv.Atoi(r.PathValue("schedule_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid schedule ID", err) + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + fixture, err := db.GetFixture(ctx, tx, fixtureID) + if err != nil { + if db.IsBadRequest(err) { + respond.NotFound(w, errors.Wrap(err, "db.GetFixture")) + return false, nil + } + return false, errors.Wrap(err, "db.GetFixture") + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user) + if err != nil { + return false, errors.Wrap(err, "fixture.CanSchedule") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to withdraw a proposal", nil) + return false, nil + } + + err = db.WithdrawFixtureSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Withdraw", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.WithdrawFixtureSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Proposal Withdrawn", "Your proposed time has been withdrawn.", nil) + respond.HXRedirect(w, "/fixtures/%d", fixtureID) + }) +} + +// CancelSchedule handles POST /fixtures/{fixture_id}/schedule/cancel +// This is a moderator-only action that requires fixtures.manage permission. +func CancelSchedule( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid fixture ID", err) + return + } + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + reason := getter.String("reschedule_reason").TrimSpace().Required().Value + if !getter.ValidateAndNotify(s, w, r) { + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + err := db.CancelFixtureSchedule(ctx, tx, fixtureID, reason, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Cancel", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.CancelFixtureSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Forfeit Declared", "The fixture has been declared a forfeit.", nil) + respond.HXRedirect(w, "/fixtures/%d", fixtureID) + }) +} diff --git a/internal/handlers/season_league_fixtures.go b/internal/handlers/season_league_fixtures.go index 22e4427..72ba860 100644 --- a/internal/handlers/season_league_fixtures.go +++ b/internal/handlers/season_league_fixtures.go @@ -24,6 +24,7 @@ func SeasonLeagueFixturesPage( var sl *db.SeasonLeague var fixtures []*db.Fixture + var scheduleMap map[int]*db.FixtureSchedule if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error @@ -35,15 +36,23 @@ func SeasonLeagueFixturesPage( } return false, errors.Wrap(err, "db.GetFixtures") } + fixtureIDs := make([]int, len(fixtures)) + for i, f := range fixtures { + fixtureIDs[i] = f.ID + } + scheduleMap, err = db.GetAcceptedSchedulesForFixtures(ctx, tx, fixtureIDs) + if err != nil { + return false, errors.Wrap(err, "db.GetAcceptedSchedulesForFixtures") + } return true, nil }); !ok { return } if r.Method == "GET" { - renderSafely(seasonsview.SeasonLeagueFixturesPage(sl.Season, sl.League, fixtures), s, r, w) + renderSafely(seasonsview.SeasonLeagueFixturesPage(sl.Season, sl.League, fixtures, scheduleMap), s, r, w) } else { - renderSafely(seasonsview.SeasonLeagueFixtures(sl.Season, sl.League, fixtures), s, r, w) + renderSafely(seasonsview.SeasonLeagueFixtures(sl.Season, sl.League, fixtures, scheduleMap), s, r, w) } }) } diff --git a/internal/handlers/season_league_team_detail.go b/internal/handlers/season_league_team_detail.go index b011d45..2c7ab71 100644 --- a/internal/handlers/season_league_team_detail.go +++ b/internal/handlers/season_league_team_detail.go @@ -32,6 +32,7 @@ func SeasonLeagueTeamDetailPage( var twr *db.TeamWithRoster var fixtures []*db.Fixture var available []*db.Player + var scheduleMap map[int]*db.FixtureSchedule if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error @@ -47,6 +48,14 @@ func SeasonLeagueTeamDetailPage( if err != nil { return false, errors.Wrap(err, "db.GetFixturesForTeam") } + fixtureIDs := make([]int, len(fixtures)) + for i, f := range fixtures { + fixtureIDs[i] = f.ID + } + scheduleMap, err = db.GetAcceptedSchedulesForFixtures(ctx, tx, fixtureIDs) + if err != nil { + return false, errors.Wrap(err, "db.GetAcceptedSchedulesForFixtures") + } available, err = db.GetPlayersNotOnTeam(ctx, tx, twr.Season.ID, twr.League.ID) if err != nil { @@ -58,6 +67,6 @@ func SeasonLeagueTeamDetailPage( return } - renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available), s, r, w) + renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available, scheduleMap), s, r, w) }) } diff --git a/internal/server/routes.go b/internal/server/routes.go index 1a9433d..c8b1453 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -188,11 +188,52 @@ func addRoutes( Method: hws.MethodDELETE, Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.SeasonLeagueDeleteFixtures(s, conn)), }, + { + Path: "/fixtures/{fixture_id}", + Method: hws.MethodGET, + Handler: handlers.FixtureDetailPage(s, conn), + }, { Path: "/fixtures/{fixture_id}", Method: hws.MethodDELETE, Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)), }, + // Fixture scheduling routes + { + Path: "/fixtures/{fixture_id}/schedule", + Method: hws.MethodPOST, + Handler: handlers.ProposeSchedule(s, conn), + }, + { + Path: "/fixtures/{fixture_id}/schedule/{schedule_id}/accept", + Method: hws.MethodPOST, + Handler: handlers.AcceptSchedule(s, conn), + }, + { + Path: "/fixtures/{fixture_id}/schedule/{schedule_id}/reject", + Method: hws.MethodPOST, + Handler: handlers.RejectSchedule(s, conn), + }, + { + Path: "/fixtures/{fixture_id}/schedule/{schedule_id}/withdraw", + Method: hws.MethodPOST, + Handler: handlers.WithdrawSchedule(s, conn), + }, + { + Path: "/fixtures/{fixture_id}/schedule/postpone", + Method: hws.MethodPOST, + Handler: handlers.PostponeSchedule(s, conn), + }, + { + Path: "/fixtures/{fixture_id}/schedule/reschedule", + Method: hws.MethodPOST, + Handler: handlers.RescheduleFixture(s, conn), + }, + { + Path: "/fixtures/{fixture_id}/schedule/cancel", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.CancelSchedule(s, conn)), + }, } teamRoutes := []hws.Route{ diff --git a/internal/validation/timefield.go b/internal/validation/timefield.go index 135a23a..7c596c0 100644 --- a/internal/validation/timefield.go +++ b/internal/validation/timefield.go @@ -48,3 +48,23 @@ func (t *TimeField) Optional() *TimeField { } return t } + +func (t *TimeField) Before(limit time.Time) *TimeField { + if !t.Value.Before(limit) { + t.getter.AddCheck(newFailedCheck( + "Date/Time invalid", + fmt.Sprintf("%s must be before %s", t.Key, limit), + )) + } + return t +} + +func (t *TimeField) After(limit time.Time) *TimeField { + if !t.Value.After(limit) { + t.getter.AddCheck(newFailedCheck( + "Date/Time invalid", + fmt.Sprintf("%s must be after %s", t.Key, limit), + )) + } + return t +} diff --git a/internal/view/seasonsview/fixture_detail.templ b/internal/view/seasonsview/fixture_detail.templ new file mode 100644 index 0000000..69a7cf2 --- /dev/null +++ b/internal/view/seasonsview/fixture_detail.templ @@ -0,0 +1,543 @@ +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 "git.haelnorr.com/h/oslstats/internal/view/baseview" +import "fmt" +import "time" + +func formatScheduleTime(t *time.Time) string { + if t == nil { + return "No time set" + } + return t.Format("Mon 2 Jan 2006 at 3:04 PM") +} + +func formatHistoryTime(unix int64) string { + return time.Unix(unix, 0).Format("2 Jan 2006 15:04") +} + +templ FixtureDetailPage( + fixture *db.Fixture, + currentSchedule *db.FixtureSchedule, + history []*db.FixtureSchedule, + canSchedule bool, + userTeamID int, +) { + {{ + permCache := contexts.Permissions(ctx) + canManage := permCache.HasPermission(permissions.FixturesManage) + backURL := fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", fixture.Season.ShortName, fixture.League.ShortName) + }} + @baseview.Layout(fmt.Sprintf("%s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) { +
+ +
+
+
+
+
+

+ { fixture.HomeTeam.Name } + vs + { fixture.AwayTeam.Name } +

+
+
+ + Round { fmt.Sprint(fixture.Round) } + + if fixture.GameWeek != nil { + + Game Week { fmt.Sprint(*fixture.GameWeek) } + + } + + { fixture.Season.Name } β€” { fixture.League.Name } + +
+
+ + Back to Fixtures + +
+
+
+ +
+ @fixtureScheduleStatus(fixture, currentSchedule, canSchedule, canManage, userTeamID) + @fixtureScheduleActions(fixture, currentSchedule, canSchedule, canManage, userTeamID) + @fixtureScheduleHistory(fixture, history) +
+
+ } +} + +templ fixtureScheduleStatus( + fixture *db.Fixture, + current *db.FixtureSchedule, + canSchedule bool, + canManage bool, + userTeamID int, +) { +
+
+

Schedule Status

+
+
+ if current == nil { + +
+
πŸ“…
+

No time scheduled

+

+ if canSchedule { + Use the form to propose a time for this fixture. + } else { + A team manager needs to propose a time for this fixture. + } +

+
+ } else if current.Status == db.ScheduleStatusPending && current.ScheduledTime != nil { + +
+
⏳
+

+ Proposed: { formatScheduleTime(current.ScheduledTime) } +

+

+ Proposed by + { current.ProposedBy.Name } + β€” awaiting response from the other team +

+ if canSchedule && userTeamID != current.ProposedByTeamID { +
+
+ +
+
+ +
+
+ } + if canSchedule && userTeamID == current.ProposedByTeamID { +
+ +
+ } +
+ } else if current.Status == db.ScheduleStatusAccepted { + +
+
βœ…
+

+ Confirmed: { formatScheduleTime(current.ScheduledTime) } +

+

+ Both teams have agreed on this time. +

+
+ } else if current.Status == db.ScheduleStatusRejected { + +
+
❌
+

Proposal Rejected

+

+ The proposed time was rejected. A new time needs to be proposed. +

+
+ } else if current.Status == db.ScheduleStatusCancelled { + +
+
🚫
+

Fixture Forfeited

+ if current.RescheduleReason != nil { +

+ { *current.RescheduleReason } +

+ } +
+ } else if current.Status == db.ScheduleStatusRescheduled { + +
+
πŸ”„
+

Rescheduled

+ if current.RescheduleReason != nil { +

+ Reason: { *current.RescheduleReason } +

+ } +

+ A new time needs to be proposed. +

+
+ } else if current.Status == db.ScheduleStatusPostponed { + +
+
⏸️
+

Postponed

+ if current.RescheduleReason != nil { +

+ Reason: { *current.RescheduleReason } +

+ } +

+ A new time needs to be proposed. +

+
+ } else if current.Status == db.ScheduleStatusWithdrawn { + +
+
↩️
+

Proposal Withdrawn

+

+ The proposed time was withdrawn. A new time needs to be proposed. +

+
+ } +
+
+} + +templ fixtureScheduleActions( + fixture *db.Fixture, + current *db.FixtureSchedule, + canSchedule bool, + canManage bool, + userTeamID int, +) { + {{ + // Determine what actions are available + showPropose := false + showReschedule := false + showPostpone := false + showCancel := false + + if canSchedule { + if current == nil { + showPropose = true + } else if current.Status == db.ScheduleStatusRejected { + showPropose = true + } else if current.Status == db.ScheduleStatusRescheduled { + showPropose = true + } else if current.Status == db.ScheduleStatusPostponed { + showPropose = true + } else if current.Status == db.ScheduleStatusWithdrawn { + showPropose = true + } else if current.Status == db.ScheduleStatusAccepted { + showReschedule = true + showPostpone = true + } + } + if canManage && current != nil && !current.Status.IsTerminal() { + showCancel = true + } + }} + if showPropose || showReschedule || showPostpone || showCancel { +
+ + if showPropose { +
+
+

Propose Time

+
+
+
+
+ + +
+ +
+
+
+ } + + if showReschedule { +
+
+

Reschedule

+
+
+
+
+ + +
+
+ + @rescheduleReasonSelect(fixture) +
+ +
+
+
+ } + + if showPostpone { +
+
+

Postpone

+
+
+
+
+ + @rescheduleReasonSelect(fixture) +
+ +
+
+
+ } + + if showCancel { +
+
+

Declare Forfeit

+
+
+

+ This action is irreversible. Declaring a forfeit will permanently cancel the fixture schedule. +

+
+
+ + @forfeitReasonSelect(fixture) +
+ +
+
+
+ } +
+ } else { + if !canSchedule && !canManage { +
+

+ Only team managers can manage fixture scheduling. +

+
+ } + } +} + +templ forfeitReasonSelect(fixture *db.Fixture) { + +} + +templ rescheduleReasonSelect(fixture *db.Fixture) { + +} + +templ fixtureScheduleHistory(fixture *db.Fixture, history []*db.FixtureSchedule) { +
+
+

Schedule History

+
+
+ if len(history) == 0 { +

No scheduling activity yet.

+ } else { +
+ for i := len(history) - 1; i >= 0; i-- { + @scheduleHistoryItem(history[i], i == len(history)-1) + } +
+ } +
+
+} + +templ scheduleHistoryItem(schedule *db.FixtureSchedule, isCurrent bool) { + {{ + statusColor := "text-subtext0" + statusBg := "bg-surface1" + statusLabel := string(schedule.Status) + switch schedule.Status { + case db.ScheduleStatusPending: + statusColor = "text-blue" + statusBg = "bg-blue/20" + statusLabel = "Pending" + case db.ScheduleStatusAccepted: + statusColor = "text-green" + statusBg = "bg-green/20" + statusLabel = "Accepted" + case db.ScheduleStatusRejected: + statusColor = "text-red" + statusBg = "bg-red/20" + statusLabel = "Rejected" + case db.ScheduleStatusRescheduled: + statusColor = "text-yellow" + statusBg = "bg-yellow/20" + statusLabel = "Rescheduled" + case db.ScheduleStatusPostponed: + statusColor = "text-peach" + statusBg = "bg-peach/20" + statusLabel = "Postponed" + case db.ScheduleStatusCancelled: + statusColor = "text-red" + statusBg = "bg-red/20" + statusLabel = "Cancelled" + case db.ScheduleStatusWithdrawn: + statusColor = "text-subtext0" + statusBg = "bg-surface1" + statusLabel = "Withdrawn" + } + }} +
+
+
+ if isCurrent { + + CURRENT + + } + + { statusLabel } + +
+ + { formatHistoryTime(schedule.CreatedAt) } + +
+
+
+ Proposed by: + { schedule.ProposedBy.Name } +
+ if schedule.ScheduledTime != nil { +
+ Time: + { formatScheduleTime(schedule.ScheduledTime) } +
+ } else { +
+ Time: + No time set +
+ } + if schedule.AcceptedBy != nil { +
+ Accepted by: + { schedule.AcceptedBy.Name } +
+ } + if schedule.RescheduleReason != nil { +
+ Reason: + { *schedule.RescheduleReason } +
+ } +
+
+} diff --git a/internal/view/seasonsview/season_league_fixtures.templ b/internal/view/seasonsview/season_league_fixtures.templ index 7db9f36..1c4ee79 100644 --- a/internal/view/seasonsview/season_league_fixtures.templ +++ b/internal/view/seasonsview/season_league_fixtures.templ @@ -5,13 +5,13 @@ 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, fixtures []*db.Fixture) { +templ SeasonLeagueFixturesPage(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) { @SeasonLeagueLayout("fixtures", season, league) { - @SeasonLeagueFixtures(season, league, fixtures) + @SeasonLeagueFixtures(season, league, fixtures, scheduleMap) } } -templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture) { +templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) { {{ permCache := contexts.Permissions(ctx) canManage := permCache.HasPermission(permissions.FixturesManage) @@ -61,7 +61,14 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
for _, fixture := range group.Fixtures { -
+ {{ + sched, hasSchedule := scheduleMap[fixture.ID] + _ = sched + }} +
R{ fmt.Sprint(fixture.Round) } @@ -74,7 +81,16 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db. { fixture.AwayTeam.Name }
-
+ if hasSchedule && sched.ScheduledTime != nil { + + { sched.ScheduledTime.Format("Mon 2 Jan 3:04 PM") } + + } else { + + TBD + + } + }
diff --git a/internal/view/seasonsview/season_league_team_detail.templ b/internal/view/seasonsview/season_league_team_detail.templ index ad607ca..136a33f 100644 --- a/internal/view/seasonsview/season_league_team_detail.templ +++ b/internal/view/seasonsview/season_league_team_detail.templ @@ -6,7 +6,7 @@ import "git.haelnorr.com/h/oslstats/internal/contexts" import "git.haelnorr.com/h/oslstats/internal/view/baseview" import "fmt" -templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player) { +templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player, scheduleMap map[int]*db.FixtureSchedule) { {{ team := twr.Team season := twr.Season @@ -54,7 +54,7 @@ templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture,
@TeamRosterSection(twr, available) - @teamFixturesPane(twr.Team, fixtures) + @teamFixturesPane(twr.Team, fixtures, scheduleMap)
@@ -394,7 +394,7 @@ templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPl } -templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture) { +templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
@@ -406,7 +406,7 @@ templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture) { } else {
for _, fixture := range fixtures { - @teamFixtureRow(team, fixture) + @teamFixtureRow(team, fixture, scheduleMap) }
} @@ -422,7 +422,7 @@ templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture) {
} -templ teamFixtureRow(team *db.Team, fixture *db.Fixture) { +templ teamFixtureRow(team *db.Team, fixture *db.Fixture, scheduleMap map[int]*db.FixtureSchedule) { {{ isHome := fixture.HomeTeamID == team.ID var opponent string @@ -431,8 +431,13 @@ templ teamFixtureRow(team *db.Team, fixture *db.Fixture) { } else { opponent = fixture.HomeTeam.Name } + sched, hasSchedule := scheduleMap[fixture.ID] + _ = sched }} -
+
GW{ fmt.Sprint(*fixture.GameWeek) } @@ -451,10 +456,16 @@ templ teamFixtureRow(team *db.Team, fixture *db.Fixture) { { opponent }
- - TBD - -
+ if hasSchedule && sched.ScheduledTime != nil { + + { sched.ScheduledTime.Format("Mon 2 Jan 3:04 PM") } + + } else { + + TBD + + } + } templ teamStatsSection() {