fixtures #2
@@ -28,6 +28,35 @@ type Fixture struct {
|
|||||||
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
||||||
HomeTeam *Team `bun:"rel:belongs-to,join:home_team_id=id"`
|
HomeTeam *Team `bun:"rel:belongs-to,join:home_team_id=id"`
|
||||||
AwayTeam *Team `bun:"rel:belongs-to,join:away_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,
|
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:"-"`
|
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
|
// 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
|
// discordID, it will automatically link that user to the player
|
||||||
func NewPlayer(ctx context.Context, tx bun.Tx, discordID string, audit *AuditMeta) (*Player, error) {
|
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',
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||||
monospace;
|
monospace;
|
||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
|
--breakpoint-lg: 64rem;
|
||||||
--breakpoint-xl: 80rem;
|
--breakpoint-xl: 80rem;
|
||||||
--breakpoint-2xl: 96rem;
|
--breakpoint-2xl: 96rem;
|
||||||
--container-sm: 24rem;
|
--container-sm: 24rem;
|
||||||
@@ -555,6 +556,9 @@
|
|||||||
.max-w-screen-2xl {
|
.max-w-screen-2xl {
|
||||||
max-width: var(--breakpoint-2xl);
|
max-width: var(--breakpoint-2xl);
|
||||||
}
|
}
|
||||||
|
.max-w-screen-lg {
|
||||||
|
max-width: var(--breakpoint-lg);
|
||||||
|
}
|
||||||
.max-w-screen-xl {
|
.max-w-screen-xl {
|
||||||
max-width: var(--breakpoint-xl);
|
max-width: var(--breakpoint-xl);
|
||||||
}
|
}
|
||||||
@@ -828,6 +832,12 @@
|
|||||||
.border-blue {
|
.border-blue {
|
||||||
border-color: var(--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-blue\/50 {
|
||||||
border-color: var(--blue);
|
border-color: var(--blue);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -888,6 +898,12 @@
|
|||||||
.bg-blue {
|
.bg-blue {
|
||||||
background-color: var(--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 {
|
.bg-blue\/20 {
|
||||||
background-color: var(--blue);
|
background-color: var(--blue);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -936,6 +952,12 @@
|
|||||||
.bg-peach {
|
.bg-peach {
|
||||||
background-color: var(--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 {
|
.bg-red {
|
||||||
background-color: var(--red);
|
background-color: var(--red);
|
||||||
}
|
}
|
||||||
@@ -996,6 +1018,9 @@
|
|||||||
.p-2\.5 {
|
.p-2\.5 {
|
||||||
padding: calc(var(--spacing) * 2.5);
|
padding: calc(var(--spacing) * 2.5);
|
||||||
}
|
}
|
||||||
|
.p-3 {
|
||||||
|
padding: calc(var(--spacing) * 3);
|
||||||
|
}
|
||||||
.p-4 {
|
.p-4 {
|
||||||
padding: calc(var(--spacing) * 4);
|
padding: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -1183,9 +1208,18 @@
|
|||||||
.text-overlay0 {
|
.text-overlay0 {
|
||||||
color: var(--overlay0);
|
color: var(--overlay0);
|
||||||
}
|
}
|
||||||
|
.text-peach {
|
||||||
|
color: var(--peach);
|
||||||
|
}
|
||||||
.text-red {
|
.text-red {
|
||||||
color: var(--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 {
|
.text-subtext0 {
|
||||||
color: var(--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\:bg-red\/25 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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\:text-blue {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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 sl *db.SeasonLeague
|
||||||
var fixtures []*db.Fixture
|
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) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
@@ -35,15 +36,23 @@ func SeasonLeagueFixturesPage(
|
|||||||
}
|
}
|
||||||
return false, errors.Wrap(err, "db.GetFixtures")
|
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
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "GET" {
|
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 {
|
} 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 twr *db.TeamWithRoster
|
||||||
var fixtures []*db.Fixture
|
var fixtures []*db.Fixture
|
||||||
var available []*db.Player
|
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) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
@@ -47,6 +48,14 @@ func SeasonLeagueTeamDetailPage(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.GetFixturesForTeam")
|
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)
|
available, err = db.GetPlayersNotOnTeam(ctx, tx, twr.Season.ID, twr.League.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,6 +67,6 @@ func SeasonLeagueTeamDetailPage(
|
|||||||
return
|
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,
|
Method: hws.MethodDELETE,
|
||||||
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.SeasonLeagueDeleteFixtures(s, conn)),
|
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}",
|
Path: "/fixtures/{fixture_id}",
|
||||||
Method: hws.MethodDELETE,
|
Method: hws.MethodDELETE,
|
||||||
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)),
|
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{
|
teamRoutes := []hws.Route{
|
||||||
|
|||||||
@@ -48,3 +48,23 @@ func (t *TimeField) Optional() *TimeField {
|
|||||||
}
|
}
|
||||||
return t
|
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 "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
import "fmt"
|
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) {
|
@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)
|
permCache := contexts.Permissions(ctx)
|
||||||
canManage := permCache.HasPermission(permissions.FixturesManage)
|
canManage := permCache.HasPermission(permissions.FixturesManage)
|
||||||
@@ -61,7 +61,14 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
|
|||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-surface1">
|
<div class="divide-y divide-surface1">
|
||||||
for _, fixture := range group.Fixtures {
|
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">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded">
|
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded">
|
||||||
R{ fmt.Sprint(fixture.Round) }
|
R{ fmt.Sprint(fixture.Round) }
|
||||||
@@ -74,7 +81,16 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
|
|||||||
{ fixture.AwayTeam.Name }
|
{ fixture.AwayTeam.Name }
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import "git.haelnorr.com/h/oslstats/internal/contexts"
|
|||||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
import "fmt"
|
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
|
team := twr.Team
|
||||||
season := twr.Season
|
season := twr.Season
|
||||||
@@ -54,7 +54,7 @@ templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture,
|
|||||||
<!-- Top row: Roster (left) + Fixtures (right) -->
|
<!-- Top row: Roster (left) + Fixtures (right) -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
@TeamRosterSection(twr, available)
|
@TeamRosterSection(twr, available)
|
||||||
@teamFixturesPane(twr.Team, fixtures)
|
@teamFixturesPane(twr.Team, fixtures, scheduleMap)
|
||||||
</div>
|
</div>
|
||||||
<!-- Stats below both -->
|
<!-- Stats below both -->
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
@@ -394,7 +394,7 @@ templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPl
|
|||||||
</script>
|
</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">
|
<section class="space-y-6">
|
||||||
<!-- Upcoming -->
|
<!-- Upcoming -->
|
||||||
<div>
|
<div>
|
||||||
@@ -406,7 +406,7 @@ templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture) {
|
|||||||
} else {
|
} else {
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||||
for _, fixture := range fixtures {
|
for _, fixture := range fixtures {
|
||||||
@teamFixtureRow(team, fixture)
|
@teamFixtureRow(team, fixture, scheduleMap)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -422,7 +422,7 @@ templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture) {
|
|||||||
</section>
|
</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
|
isHome := fixture.HomeTeamID == team.ID
|
||||||
var opponent string
|
var opponent string
|
||||||
@@ -431,8 +431,13 @@ templ teamFixtureRow(team *db.Team, fixture *db.Fixture) {
|
|||||||
} else {
|
} else {
|
||||||
opponent = fixture.HomeTeam.Name
|
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">
|
<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">
|
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded shrink-0">
|
||||||
GW{ fmt.Sprint(*fixture.GameWeek) }
|
GW{ fmt.Sprint(*fixture.GameWeek) }
|
||||||
@@ -451,10 +456,16 @@ templ teamFixtureRow(team *db.Team, fixture *db.Fixture) {
|
|||||||
{ opponent }
|
{ opponent }
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-subtext1 shrink-0">
|
if hasSchedule && sched.ScheduledTime != nil {
|
||||||
TBD
|
<span class="text-xs text-green font-medium shrink-0">
|
||||||
</span>
|
{ sched.ScheduledTime.Format("Mon 2 Jan 3:04 PM") }
|
||||||
</div>
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-xs text-subtext1 shrink-0">
|
||||||
|
TBD
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ teamStatsSection() {
|
templ teamStatsSection() {
|
||||||
|
|||||||
Reference in New Issue
Block a user