fixtures #2

Merged
h merged 20 commits from fixtures into master 2026-02-23 20:38:26 +11:00
13 changed files with 1703 additions and 18 deletions
Showing only changes of commit 971960d0cb - Show all commits

View File

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

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

View 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
},
)
}

View File

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

View File

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

View 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)
})
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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