fixtures #2
@@ -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,
|
||||
|
||||
426
internal/db/fixture_schedule.go
Normal file
426
internal/db/fixture_schedule.go
Normal file
@@ -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
|
||||
}
|
||||
58
internal/db/migrations/20260221103653_fixture_schedules.go
Normal file
58
internal/db/migrations/20260221103653_fixture_schedules.go
Normal file
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
462
internal/handlers/fixture_detail.go
Normal file
462
internal/handlers/fixture_detail.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
543
internal/view/seasonsview/fixture_detail.templ
Normal file
543
internal/view/seasonsview/fixture_detail.templ
Normal file
@@ -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)) {
|
||||
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-2">
|
||||
<h1 class="text-3xl font-bold text-text">
|
||||
{ fixture.HomeTeam.Name }
|
||||
<span class="text-subtext0 font-normal">vs</span>
|
||||
{ fixture.AwayTeam.Name }
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="px-2 py-0.5 bg-surface1 text-subtext0 rounded text-xs font-mono">
|
||||
Round { fmt.Sprint(fixture.Round) }
|
||||
</span>
|
||||
if fixture.GameWeek != nil {
|
||||
<span class="px-2 py-0.5 bg-surface1 text-subtext0 rounded text-xs font-mono">
|
||||
Game Week { fmt.Sprint(*fixture.GameWeek) }
|
||||
</span>
|
||||
}
|
||||
<span class="text-subtext1 text-sm">
|
||||
{ fixture.Season.Name } — { fixture.League.Name }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={ templ.SafeURL(backURL) }
|
||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||
bg-surface1 hover:bg-surface2 text-text transition"
|
||||
>
|
||||
Back to Fixtures
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Schedule Status + Actions -->
|
||||
<div class="space-y-6">
|
||||
@fixtureScheduleStatus(fixture, currentSchedule, canSchedule, canManage, userTeamID)
|
||||
@fixtureScheduleActions(fixture, currentSchedule, canSchedule, canManage, userTeamID)
|
||||
@fixtureScheduleHistory(fixture, history)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ fixtureScheduleStatus(
|
||||
fixture *db.Fixture,
|
||||
current *db.FixtureSchedule,
|
||||
canSchedule bool,
|
||||
canManage bool,
|
||||
userTeamID int,
|
||||
) {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Schedule Status</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
if current == nil {
|
||||
<!-- No schedule yet -->
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl mb-3">📅</div>
|
||||
<p class="text-lg text-text font-medium">No time scheduled</p>
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
if canSchedule {
|
||||
Use the form to propose a time for this fixture.
|
||||
} else {
|
||||
A team manager needs to propose a time for this fixture.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
} else if current.Status == db.ScheduleStatusPending && current.ScheduledTime != nil {
|
||||
<!-- Pending proposal with time -->
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl mb-3">⏳</div>
|
||||
<p class="text-lg text-text font-medium">
|
||||
Proposed: { formatScheduleTime(current.ScheduledTime) }
|
||||
</p>
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
Proposed by
|
||||
<span class="text-text font-medium">{ current.ProposedBy.Name }</span>
|
||||
— awaiting response from the other team
|
||||
</p>
|
||||
if canSchedule && userTeamID != current.ProposedByTeamID {
|
||||
<div class="flex justify-center gap-3 mt-4">
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/fixtures/%d/schedule/%d/accept", fixture.ID, current.ID) }
|
||||
hx-swap="none"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</form>
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/fixtures/%d/schedule/%d/reject", fixture.ID, current.ID) }
|
||||
hx-swap="none"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
if canSchedule && userTeamID == current.ProposedByTeamID {
|
||||
<div class="flex justify-center mt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click={ "window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Withdraw Proposal', message: 'Are you sure you want to withdraw your proposed time?', action: () => htmx.ajax('POST', '/fixtures/" + fmt.Sprint(fixture.ID) + "/schedule/" + fmt.Sprint(current.ID) + "/withdraw', { swap: 'none' }) } }))" }
|
||||
class="px-4 py-2 bg-surface1 hover:bg-surface2 text-text rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Withdraw Proposal
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} else if current.Status == db.ScheduleStatusAccepted {
|
||||
<!-- Accepted / Confirmed -->
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl mb-3">✅</div>
|
||||
<p class="text-lg text-green font-medium">
|
||||
Confirmed: { formatScheduleTime(current.ScheduledTime) }
|
||||
</p>
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
Both teams have agreed on this time.
|
||||
</p>
|
||||
</div>
|
||||
} else if current.Status == db.ScheduleStatusRejected {
|
||||
<!-- Rejected -->
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl mb-3">❌</div>
|
||||
<p class="text-lg text-red font-medium">Proposal Rejected</p>
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
The proposed time was rejected. A new time needs to be proposed.
|
||||
</p>
|
||||
</div>
|
||||
} else if current.Status == db.ScheduleStatusCancelled {
|
||||
<!-- Cancelled / Forfeit -->
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl mb-3">🚫</div>
|
||||
<p class="text-lg text-red font-medium">Fixture Forfeited</p>
|
||||
if current.RescheduleReason != nil {
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
{ *current.RescheduleReason }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
} else if current.Status == db.ScheduleStatusRescheduled {
|
||||
<!-- Rescheduled (terminal - new proposal should follow) -->
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl mb-3">🔄</div>
|
||||
<p class="text-lg text-yellow font-medium">Rescheduled</p>
|
||||
if current.RescheduleReason != nil {
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
Reason: { *current.RescheduleReason }
|
||||
</p>
|
||||
}
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
A new time needs to be proposed.
|
||||
</p>
|
||||
</div>
|
||||
} else if current.Status == db.ScheduleStatusPostponed {
|
||||
<!-- Postponed (terminal - new proposal should follow) -->
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl mb-3">⏸️</div>
|
||||
<p class="text-lg text-peach font-medium">Postponed</p>
|
||||
if current.RescheduleReason != nil {
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
Reason: { *current.RescheduleReason }
|
||||
</p>
|
||||
}
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
A new time needs to be proposed.
|
||||
</p>
|
||||
</div>
|
||||
} else if current.Status == db.ScheduleStatusWithdrawn {
|
||||
<!-- Withdrawn -->
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl mb-3">↩️</div>
|
||||
<p class="text-lg text-subtext0 font-medium">Proposal Withdrawn</p>
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
The proposed time was withdrawn. A new time needs to be proposed.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
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 {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Propose Time -->
|
||||
if showPropose {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
||||
<h3 class="text-md font-bold text-text">Propose Time</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/fixtures/%d/schedule", fixture.ID) }
|
||||
hx-swap="none"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-subtext0 mb-1">Date & Time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="scheduled_time"
|
||||
required
|
||||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||
focus:border-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Propose Time
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Reschedule -->
|
||||
if showReschedule {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
||||
<h3 class="text-md font-bold text-text">Reschedule</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/fixtures/%d/schedule/reschedule", fixture.ID) }
|
||||
hx-swap="none"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-subtext0 mb-1">New Date & Time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="scheduled_time"
|
||||
required
|
||||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||
focus:border-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
|
||||
@rescheduleReasonSelect(fixture)
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full px-4 py-2 bg-yellow hover:bg-yellow/80 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Reschedule
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Postpone -->
|
||||
if showPostpone {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
||||
<h3 class="text-md font-bold text-text">Postpone</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
|
||||
@rescheduleReasonSelect(fixture)
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click={ fmt.Sprintf("if (!$el.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Postpone Fixture', message: 'Are you sure you want to postpone this fixture? The current schedule will be cleared.', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/postpone', { swap: 'none', values: { reschedule_reason: $el.parentElement.querySelector('select').value } }) } }))", fixture.ID) }
|
||||
class="w-full px-4 py-2 bg-peach hover:bg-peach/80 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Postpone Fixture
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Declare Forfeit (moderator only) -->
|
||||
if showCancel {
|
||||
<div class="bg-mantle border border-red/30 rounded-lg">
|
||||
<div class="bg-red/10 border-b border-red/30 px-4 py-3 rounded-t-lg">
|
||||
<h3 class="text-md font-bold text-red">Declare Forfeit</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<p class="text-xs text-red/80 mb-3 font-medium">
|
||||
This action is irreversible. Declaring a forfeit will permanently cancel the fixture schedule.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-subtext0 mb-1">Forfeiting Team</label>
|
||||
@forfeitReasonSelect(fixture)
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click={ fmt.Sprintf("if (!$el.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Declare Forfeit', message: 'This action is IRREVERSIBLE. The fixture schedule will be permanently cancelled. Are you sure?', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/cancel', { swap: 'none', values: { reschedule_reason: $el.parentElement.querySelector('select').value } }) } }))", fixture.ID) }
|
||||
class="w-full px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Declare Forfeit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
if !canSchedule && !canManage {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||||
<p class="text-subtext1 text-sm">
|
||||
Only team managers can manage fixture scheduling.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templ forfeitReasonSelect(fixture *db.Fixture) {
|
||||
<select
|
||||
name="reschedule_reason"
|
||||
required
|
||||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||
focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||
>
|
||||
<option value="" disabled selected>Select forfeiting team</option>
|
||||
<option value={ fmt.Sprintf("%s Forfeit", fixture.HomeTeam.Name) }>
|
||||
{ fixture.HomeTeam.Name } Forfeit
|
||||
</option>
|
||||
<option value={ fmt.Sprintf("%s Forfeit", fixture.AwayTeam.Name) }>
|
||||
{ fixture.AwayTeam.Name } Forfeit
|
||||
</option>
|
||||
</select>
|
||||
}
|
||||
|
||||
templ rescheduleReasonSelect(fixture *db.Fixture) {
|
||||
<select
|
||||
name="reschedule_reason"
|
||||
required
|
||||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||
focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||
>
|
||||
<option value="" disabled selected>Select a reason</option>
|
||||
<option value="Mutually Agreed">Mutually Agreed</option>
|
||||
<option value={ fmt.Sprintf("%s Unavailable", fixture.HomeTeam.Name) }>
|
||||
{ fixture.HomeTeam.Name } Unavailable
|
||||
</option>
|
||||
<option value={ fmt.Sprintf("%s Unavailable", fixture.AwayTeam.Name) }>
|
||||
{ fixture.AwayTeam.Name } Unavailable
|
||||
</option>
|
||||
<option value={ fmt.Sprintf("%s No-show", fixture.HomeTeam.Name) }>
|
||||
{ fixture.HomeTeam.Name } No-show
|
||||
</option>
|
||||
<option value={ fmt.Sprintf("%s No-show", fixture.AwayTeam.Name) }>
|
||||
{ fixture.AwayTeam.Name } No-show
|
||||
</option>
|
||||
</select>
|
||||
}
|
||||
|
||||
templ fixtureScheduleHistory(fixture *db.Fixture, history []*db.FixtureSchedule) {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Schedule History</h2>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
if len(history) == 0 {
|
||||
<p class="text-subtext1 text-sm text-center py-4">No scheduling activity yet.</p>
|
||||
} else {
|
||||
<div class="space-y-3">
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
@scheduleHistoryItem(history[i], i == len(history)-1)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}}
|
||||
<div class={ "border rounded-lg p-3", templ.KV("border-surface1", !isCurrent), templ.KV("border-blue/30 bg-blue/5", isCurrent) }>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
if isCurrent {
|
||||
<span class="text-xs px-1.5 py-0.5 bg-blue/20 text-blue rounded font-medium">
|
||||
CURRENT
|
||||
</span>
|
||||
}
|
||||
<span class={ "text-xs px-2 py-0.5 rounded font-medium", statusBg, statusColor }>
|
||||
{ statusLabel }
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-subtext1">
|
||||
{ formatHistoryTime(schedule.CreatedAt) }
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-subtext0">Proposed by:</span>
|
||||
<span class="text-text font-medium">{ schedule.ProposedBy.Name }</span>
|
||||
</div>
|
||||
if schedule.ScheduledTime != nil {
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-subtext0">Time:</span>
|
||||
<span class="text-text">{ formatScheduleTime(schedule.ScheduledTime) }</span>
|
||||
</div>
|
||||
} else {
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-subtext0">Time:</span>
|
||||
<span class="text-subtext1 italic">No time set</span>
|
||||
</div>
|
||||
}
|
||||
if schedule.AcceptedBy != nil {
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-subtext0">Accepted by:</span>
|
||||
<span class="text-text font-medium">{ schedule.AcceptedBy.Name }</span>
|
||||
</div>
|
||||
}
|
||||
if schedule.RescheduleReason != nil {
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-subtext0">Reason:</span>
|
||||
<span class="text-text">{ *schedule.RescheduleReason }</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -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.
|
||||
</div>
|
||||
<div class="divide-y divide-surface1">
|
||||
for _, fixture := range group.Fixtures {
|
||||
<div class="px-4 py-3 flex items-center justify-between">
|
||||
{{
|
||||
sched, hasSchedule := scheduleMap[fixture.ID]
|
||||
_ = sched
|
||||
}}
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
||||
class="px-4 py-3 flex items-center justify-between hover:bg-surface1 transition hover:cursor-pointer block"
|
||||
>
|
||||
<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) }
|
||||
@@ -74,7 +81,16 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
|
||||
{ fixture.AwayTeam.Name }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
if hasSchedule && sched.ScheduledTime != nil {
|
||||
<span class="text-xs text-green font-medium">
|
||||
{ sched.ScheduledTime.Format("Mon 2 Jan 3:04 PM") }
|
||||
</span>
|
||||
} else {
|
||||
<span class="text-xs text-subtext1">
|
||||
TBD
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
<!-- Top row: Roster (left) + Fixtures (right) -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@TeamRosterSection(twr, available)
|
||||
@teamFixturesPane(twr.Team, fixtures)
|
||||
@teamFixturesPane(twr.Team, fixtures, scheduleMap)
|
||||
</div>
|
||||
<!-- Stats below both -->
|
||||
<div class="mt-6">
|
||||
@@ -394,7 +394,7 @@ templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPl
|
||||
</script>
|
||||
}
|
||||
|
||||
templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture) {
|
||||
templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
|
||||
<section class="space-y-6">
|
||||
<!-- Upcoming -->
|
||||
<div>
|
||||
@@ -406,7 +406,7 @@ templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture) {
|
||||
} else {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||
for _, fixture := range fixtures {
|
||||
@teamFixtureRow(team, fixture)
|
||||
@teamFixtureRow(team, fixture, scheduleMap)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -422,7 +422,7 @@ templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture) {
|
||||
</section>
|
||||
}
|
||||
|
||||
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
|
||||
}}
|
||||
<div class="px-4 py-3 flex items-center justify-between gap-3">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
||||
class="px-4 py-3 flex items-center justify-between gap-3 hover:bg-surface1 transition hover:cursor-pointer block"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded shrink-0">
|
||||
GW{ fmt.Sprint(*fixture.GameWeek) }
|
||||
@@ -451,10 +456,16 @@ templ teamFixtureRow(team *db.Team, fixture *db.Fixture) {
|
||||
{ opponent }
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-subtext1 shrink-0">
|
||||
TBD
|
||||
</span>
|
||||
</div>
|
||||
if hasSchedule && sched.ScheduledTime != nil {
|
||||
<span class="text-xs text-green font-medium shrink-0">
|
||||
{ sched.ScheduledTime.Format("Mon 2 Jan 3:04 PM") }
|
||||
</span>
|
||||
} else {
|
||||
<span class="text-xs text-subtext1 shrink-0">
|
||||
TBD
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
|
||||
templ teamStatsSection() {
|
||||
|
||||
Reference in New Issue
Block a user