diff --git a/internal/db/migrations/20260315140000_add_playoff_series_schedules.go b/internal/db/migrations/20260315140000_add_playoff_series_schedules.go new file mode 100644 index 0000000..0a23f45 --- /dev/null +++ b/internal/db/migrations/20260315140000_add_playoff_series_schedules.go @@ -0,0 +1,58 @@ +package migrations + +import ( + "context" + + "git.haelnorr.com/h/oslstats/internal/db" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister( + // UP migration + func(ctx context.Context, conn *bun.DB) error { + _, err := conn.NewCreateTable(). + Model((*db.PlayoffSeriesSchedule)(nil)). + IfNotExists(). + ForeignKey(`("series_id") REFERENCES "playoff_series" ("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 series_id for faster lookups + _, err = conn.NewCreateIndex(). + Model((*db.PlayoffSeriesSchedule)(nil)). + Index("idx_playoff_series_schedules_series_id"). + Column("series_id"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Create index on status for filtering + _, err = conn.NewCreateIndex(). + Model((*db.PlayoffSeriesSchedule)(nil)). + Index("idx_playoff_series_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.PlayoffSeriesSchedule)(nil)). + IfExists(). + Exec(ctx) + return err + }, + ) +} diff --git a/internal/db/playoff.go b/internal/db/playoff.go index 01a2bdd..cc9965d 100644 --- a/internal/db/playoff.go +++ b/internal/db/playoff.go @@ -324,3 +324,224 @@ func AutoForfeitUnplayedFixtures( return len(unplayed), nil } + +// GetPlayoffSeriesByID retrieves a single playoff series with all relations needed +// for the series detail page. +func GetPlayoffSeriesByID( + ctx context.Context, + tx bun.Tx, + seriesID int, +) (*PlayoffSeries, error) { + series := new(PlayoffSeries) + err := tx.NewSelect(). + Model(series). + Where("ps.id = ?", seriesID). + Relation("Bracket"). + Relation("Bracket.Season"). + Relation("Bracket.League"). + Relation("Bracket.Series"). + Relation("Team1"). + Relation("Team2"). + Relation("Winner"). + Relation("Loser"). + Relation("Matches", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Order("pm.match_number ASC") + }). + 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 series, nil +} + +// CanScheduleSeries checks if the user is a manager of one of the teams in the series. +// Returns (canSchedule, teamID) where teamID is the team the user manages (0 if not a manager). +// Both teams must be assigned for scheduling to be possible. +func CanScheduleSeries( + ctx context.Context, + tx bun.Tx, + series *PlayoffSeries, + user *User, +) (bool, int, error) { + if user == nil || user.Player == nil { + return false, 0, nil + } + if series.Team1ID == nil || series.Team2ID == 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{*series.Team1ID, *series.Team2ID})). + Where("season_id = ?", series.Bracket.SeasonID). + Where("league_id = ?", series.Bracket.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 +} + +// GetSeriesTeamRosters returns rosters for both teams in a series. +// Returns map["team1"|"team2"] -> []*PlayerWithPlayStatus +func GetSeriesTeamRosters( + ctx context.Context, + tx bun.Tx, + series *PlayoffSeries, +) (map[string][]*PlayerWithPlayStatus, error) { + if series == nil { + return nil, errors.New("series cannot be nil") + } + + rosters := map[string][]*PlayerWithPlayStatus{} + + if series.Team1ID != nil { + team1Rosters := []*TeamRoster{} + err := tx.NewSelect(). + Model(&team1Rosters). + Where("tr.team_id = ?", *series.Team1ID). + Where("tr.season_id = ?", series.Bracket.SeasonID). + Where("tr.league_id = ?", series.Bracket.LeagueID). + Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Relation("User") + }). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect team1 roster") + } + for _, tr := range team1Rosters { + rosters["team1"] = append(rosters["team1"], &PlayerWithPlayStatus{ + Player: tr.Player, + Played: false, + IsManager: tr.IsManager, + }) + } + } + + if series.Team2ID != nil { + team2Rosters := []*TeamRoster{} + err := tx.NewSelect(). + Model(&team2Rosters). + Where("tr.team_id = ?", *series.Team2ID). + Where("tr.season_id = ?", series.Bracket.SeasonID). + Where("tr.league_id = ?", series.Bracket.LeagueID). + Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Relation("User") + }). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect team2 roster") + } + for _, tr := range team2Rosters { + rosters["team2"] = append(rosters["team2"], &PlayerWithPlayStatus{ + Player: tr.Player, + Played: false, + IsManager: tr.IsManager, + }) + } + } + + return rosters, nil +} + +// ComputeSeriesPreview computes standings comparison data for the two teams in a series. +// Uses the same logic as ComputeMatchPreview but takes a series instead of a fixture. +func ComputeSeriesPreview( + ctx context.Context, + tx bun.Tx, + series *PlayoffSeries, +) (*MatchPreviewData, error) { + if series == nil || series.Bracket == nil { + return nil, errors.New("series and bracket cannot be nil") + } + + seasonID := series.Bracket.SeasonID + leagueID := series.Bracket.LeagueID + + // Get all teams in this season+league + allTeams, err := GetTeamsForSeasonLeague(ctx, tx, seasonID, leagueID) + if err != nil { + return nil, errors.Wrap(err, "GetTeamsForSeasonLeague") + } + + // Get all allocated fixtures for the season+league + allFixtures, err := GetAllocatedFixtures(ctx, tx, seasonID, leagueID) + if err != nil { + return nil, errors.Wrap(err, "GetAllocatedFixtures") + } + + // Get finalized results + allFixtureIDs := make([]int, len(allFixtures)) + for i, f := range allFixtures { + allFixtureIDs[i] = f.ID + } + allResultMap, err := GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs) + if err != nil { + return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures") + } + + // Get accepted schedules for ordering recent games + allScheduleMap, err := GetAcceptedSchedulesForFixtures(ctx, tx, allFixtureIDs) + if err != nil { + return nil, errors.Wrap(err, "GetAcceptedSchedulesForFixtures") + } + + // Compute leaderboard + leaderboard := ComputeLeaderboard(allTeams, allFixtures, allResultMap) + + preview := &MatchPreviewData{ + TotalTeams: len(leaderboard), + } + + team1ID := 0 + team2ID := 0 + if series.Team1ID != nil { + team1ID = *series.Team1ID + } + if series.Team2ID != nil { + team2ID = *series.Team2ID + } + + for _, entry := range leaderboard { + if entry.Team.ID == team1ID { + preview.HomePosition = entry.Position + preview.HomeRecord = entry.Record + } + if entry.Team.ID == team2ID { + preview.AwayPosition = entry.Position + preview.AwayRecord = entry.Record + } + } + if preview.HomeRecord == nil { + preview.HomeRecord = &TeamRecord{} + } + if preview.AwayRecord == nil { + preview.AwayRecord = &TeamRecord{} + } + + // Compute recent games (last 5) for each team + if team1ID > 0 { + preview.HomeRecentGames = ComputeRecentGames( + team1ID, allFixtures, allResultMap, allScheduleMap, 5, + ) + } + if team2ID > 0 { + preview.AwayRecentGames = ComputeRecentGames( + team2ID, allFixtures, allResultMap, allScheduleMap, 5, + ) + } + + return preview, nil +} diff --git a/internal/db/playoff_schedule.go b/internal/db/playoff_schedule.go new file mode 100644 index 0000000..8f66dc0 --- /dev/null +++ b/internal/db/playoff_schedule.go @@ -0,0 +1,370 @@ +package db + +import ( + "context" + "time" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// PlayoffSeriesSchedule represents a schedule proposal for a playoff series. +// Mirrors FixtureSchedule but references a series instead of a fixture. +type PlayoffSeriesSchedule struct { + bun.BaseModel `bun:"table:playoff_series_schedules,alias:pss"` + + ID int `bun:"id,pk,autoincrement"` + SeriesID 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"` + + Series *PlayoffSeries `bun:"rel:belongs-to,join:series_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"` +} + +// GetCurrentSeriesSchedule returns the most recent schedule record for a series. +// Returns nil, nil if no schedule exists. +func GetCurrentSeriesSchedule(ctx context.Context, tx bun.Tx, seriesID int) (*PlayoffSeriesSchedule, error) { + schedule := new(PlayoffSeriesSchedule) + err := tx.NewSelect(). + Model(schedule). + Where("series_id = ?", seriesID). + 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 +} + +// GetSeriesScheduleHistory returns all schedule records for a series in chronological order +func GetSeriesScheduleHistory(ctx context.Context, tx bun.Tx, seriesID int) ([]*PlayoffSeriesSchedule, error) { + schedules, err := GetList[PlayoffSeriesSchedule](tx). + Where("series_id = ?", seriesID). + Order("created_at ASC", "id ASC"). + Relation("ProposedBy"). + Relation("AcceptedBy"). + GetAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "GetList") + } + return schedules, nil +} + +// ProposeSeriesSchedule creates a new pending schedule proposal for a series. +// Cannot propose on cancelled or accepted schedules. +func ProposeSeriesSchedule( + ctx context.Context, + tx bun.Tx, + seriesID, proposedByTeamID int, + scheduledTime time.Time, + audit *AuditMeta, +) (*PlayoffSeriesSchedule, error) { + current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return nil, errors.Wrap(err, "GetCurrentSeriesSchedule") + } + if current != nil { + switch current.Status { + case ScheduleStatusCancelled: + return nil, BadRequest("cannot propose a new time for a cancelled series") + case ScheduleStatusAccepted: + return nil, BadRequest("series already has an accepted schedule; use reschedule instead") + case ScheduleStatusPending: + // Supersede existing pending record + 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, withdrawn are terminal — safe to create a new proposal + } + } + + schedule := &PlayoffSeriesSchedule{ + SeriesID: seriesID, + ScheduledTime: &scheduledTime, + ProposedByTeamID: proposedByTeamID, + Status: ScheduleStatusPending, + CreatedAt: time.Now().Unix(), + } + err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{ + Action: "series_schedule.propose", + ResourceType: "playoff_series_schedule", + ResourceID: seriesID, + Details: map[string]any{ + "series_id": seriesID, + "proposed_by": proposedByTeamID, + "scheduled_time": scheduledTime, + }, + }).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "Insert") + } + return schedule, nil +} + +// AcceptSeriesSchedule accepts a pending schedule proposal. +// The acceptedByTeamID must be the other team (not the proposer). +func AcceptSeriesSchedule( + ctx context.Context, + tx bun.Tx, + scheduleID, acceptedByTeamID int, + audit *AuditMeta, +) error { + schedule, err := GetByID[PlayoffSeriesSchedule](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: "series_schedule.accept", + ResourceType: "playoff_series_schedule", + ResourceID: scheduleID, + Details: map[string]any{ + "series_id": schedule.SeriesID, + "accepted_by": acceptedByTeamID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// RejectSeriesSchedule rejects a pending schedule proposal. +func RejectSeriesSchedule( + ctx context.Context, + tx bun.Tx, + scheduleID int, + audit *AuditMeta, +) error { + schedule, err := GetByID[PlayoffSeriesSchedule](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: "series_schedule.reject", + ResourceType: "playoff_series_schedule", + ResourceID: scheduleID, + Details: map[string]any{ + "series_id": schedule.SeriesID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// RescheduleSeriesSchedule marks the current accepted schedule as rescheduled +// and creates a new pending proposal with the new time. +func RescheduleSeriesSchedule( + ctx context.Context, + tx bun.Tx, + seriesID, proposedByTeamID int, + newTime time.Time, + reason string, + audit *AuditMeta, +) (*PlayoffSeriesSchedule, error) { + current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return nil, errors.Wrap(err, "GetCurrentSeriesSchedule") + } + 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 := &PlayoffSeriesSchedule{ + SeriesID: seriesID, + ScheduledTime: &newTime, + ProposedByTeamID: proposedByTeamID, + Status: ScheduleStatusPending, + CreatedAt: time.Now().Unix(), + } + err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{ + Action: "series_schedule.reschedule", + ResourceType: "playoff_series_schedule", + ResourceID: seriesID, + Details: map[string]any{ + "series_id": seriesID, + "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 +} + +// PostponeSeriesSchedule marks the current accepted schedule as postponed. +// This is a terminal state — a new proposal can be created afterwards. +func PostponeSeriesSchedule( + ctx context.Context, + tx bun.Tx, + seriesID int, + reason string, + audit *AuditMeta, +) error { + current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return errors.Wrap(err, "GetCurrentSeriesSchedule") + } + 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: "series_schedule.postpone", + ResourceType: "playoff_series_schedule", + ResourceID: seriesID, + Details: map[string]any{ + "series_id": seriesID, + "reason": reason, + "old_schedule_id": current.ID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// WithdrawSeriesSchedule allows the proposer to withdraw their pending proposal. +// Only the team that proposed can withdraw it. +func WithdrawSeriesSchedule( + ctx context.Context, + tx bun.Tx, + scheduleID, withdrawByTeamID int, + audit *AuditMeta, +) error { + schedule, err := GetByID[PlayoffSeriesSchedule](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: "series_schedule.withdraw", + ResourceType: "playoff_series_schedule", + ResourceID: scheduleID, + Details: map[string]any{ + "series_id": schedule.SeriesID, + "withdrawn_by": withdrawByTeamID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// CancelSeriesSchedule marks the current schedule as cancelled. This is a terminal state. +// Requires playoffs.manage permission (moderator-level). +func CancelSeriesSchedule( + ctx context.Context, + tx bun.Tx, + seriesID int, + reason string, + audit *AuditMeta, +) error { + current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return errors.Wrap(err, "GetCurrentSeriesSchedule") + } + 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: "series_schedule.cancel", + ResourceType: "playoff_series_schedule", + ResourceID: seriesID, + Details: map[string]any{ + "series_id": seriesID, + "reason": reason, + "schedule_id": current.ID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 6a84a71..bfc81c6 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -553,6 +553,9 @@ .w-14 { width: calc(var(--spacing) * 14); } + .w-16 { + width: calc(var(--spacing) * 16); + } .w-20 { width: calc(var(--spacing) * 20); } diff --git a/internal/handlers/series_detail.go b/internal/handlers/series_detail.go new file mode 100644 index 0000000..7798411 --- /dev/null +++ b/internal/handlers/series_detail.go @@ -0,0 +1,306 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/respond" + "git.haelnorr.com/h/oslstats/internal/throw" + "git.haelnorr.com/h/oslstats/internal/view/seasonsview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// SeriesDetailPage redirects to the default tab (overview) +func SeriesDetailPage( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series ID", err) + return + } + + http.Redirect(w, r, fmt.Sprintf("/series/%d/overview", seriesID), http.StatusSeeOther) + }) +} + +// SeriesDetailOverviewPage renders the overview tab of the series detail page +func SeriesDetailOverviewPage( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series ID", err) + return + } + + var series *db.PlayoffSeries + var currentSchedule *db.PlayoffSeriesSchedule + var canSchedule bool + var userTeamID int + var rosters map[string][]*db.PlayerWithPlayStatus + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + + currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule") + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err = db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + + rosters, err = db.GetSeriesTeamRosters(ctx, tx, series) + if err != nil { + return false, errors.Wrap(err, "db.GetSeriesTeamRosters") + } + + return true, nil + }); !ok { + return + } + + if r.Method == "GET" { + renderSafely(seasonsview.SeriesDetailOverviewPage( + series, currentSchedule, canSchedule, userTeamID, rosters, + ), s, r, w) + } else { + renderSafely(seasonsview.SeriesDetailOverviewContent( + series, currentSchedule, canSchedule, userTeamID, rosters, + ), s, r, w) + } + }) +} + +// SeriesDetailPreviewPage renders the match preview tab of the series detail page +func SeriesDetailPreviewPage( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series ID", err) + return + } + + var series *db.PlayoffSeries + var currentSchedule *db.PlayoffSeriesSchedule + var rosters map[string][]*db.PlayerWithPlayStatus + var previewData *db.MatchPreviewData + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + + currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule") + } + + rosters, err = db.GetSeriesTeamRosters(ctx, tx, series) + if err != nil { + return false, errors.Wrap(err, "db.GetSeriesTeamRosters") + } + + previewData, err = db.ComputeSeriesPreview(ctx, tx, series) + if err != nil { + return false, errors.Wrap(err, "db.ComputeSeriesPreview") + } + + return true, nil + }); !ok { + return + } + + // If completed, redirect to analysis instead + if series.Status == db.SeriesStatusCompleted { + if r.Method == "GET" { + http.Redirect(w, r, fmt.Sprintf("/series/%d/analysis", seriesID), http.StatusSeeOther) + } else { + respond.HXRedirect(w, "/series/%d/analysis", seriesID) + } + return + } + + if r.Method == "GET" { + renderSafely(seasonsview.SeriesDetailPreviewPage( + series, currentSchedule, rosters, previewData, + ), s, r, w) + } else { + renderSafely(seasonsview.SeriesDetailPreviewContent( + series, rosters, previewData, + ), s, r, w) + } + }) +} + +// SeriesDetailAnalysisPage renders the match analysis tab of the series detail page +func SeriesDetailAnalysisPage( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series ID", err) + return + } + + var series *db.PlayoffSeries + var currentSchedule *db.PlayoffSeriesSchedule + var rosters map[string][]*db.PlayerWithPlayStatus + var previewData *db.MatchPreviewData + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + + currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule") + } + + rosters, err = db.GetSeriesTeamRosters(ctx, tx, series) + if err != nil { + return false, errors.Wrap(err, "db.GetSeriesTeamRosters") + } + + previewData, err = db.ComputeSeriesPreview(ctx, tx, series) + if err != nil { + return false, errors.Wrap(err, "db.ComputeSeriesPreview") + } + + return true, nil + }); !ok { + return + } + + // If not completed, redirect to preview instead + if series.Status != db.SeriesStatusCompleted { + if r.Method == "GET" { + http.Redirect(w, r, fmt.Sprintf("/series/%d/preview", seriesID), http.StatusSeeOther) + } else { + respond.HXRedirect(w, "/series/%d/preview", seriesID) + } + return + } + + if r.Method == "GET" { + renderSafely(seasonsview.SeriesDetailAnalysisPage( + series, currentSchedule, rosters, previewData, + ), s, r, w) + } else { + renderSafely(seasonsview.SeriesDetailAnalysisContent( + series, rosters, previewData, + ), s, r, w) + } + }) +} + +// SeriesDetailSchedulePage renders the schedule tab of the series detail page +func SeriesDetailSchedulePage( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series ID", err) + return + } + + var series *db.PlayoffSeries + var currentSchedule *db.PlayoffSeriesSchedule + var history []*db.PlayoffSeriesSchedule + 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 + series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + + currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule") + } + + history, err = db.GetSeriesScheduleHistory(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetSeriesScheduleHistory") + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err = db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + + return true, nil + }); !ok { + return + } + + // If completed, redirect to overview + if series.Status == db.SeriesStatusCompleted { + if r.Method == "GET" { + http.Redirect(w, r, fmt.Sprintf("/series/%d/overview", seriesID), http.StatusSeeOther) + } else { + respond.HXRedirect(w, "/series/%d/overview", seriesID) + } + return + } + + if r.Method == "GET" { + renderSafely(seasonsview.SeriesDetailSchedulePage( + series, currentSchedule, history, canSchedule, userTeamID, + ), s, r, w) + } else { + renderSafely(seasonsview.SeriesDetailScheduleContent( + series, currentSchedule, history, canSchedule, userTeamID, + ), s, r, w) + } + }) +} diff --git a/internal/handlers/series_schedule.go b/internal/handlers/series_schedule.go new file mode 100644 index 0000000..0fd1b6f --- /dev/null +++ b/internal/handlers/series_schedule.go @@ -0,0 +1,410 @@ +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/timefmt" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// ProposeSeriesSchedule handles POST /series/{series_id}/schedule +func ProposeSeriesSchedule( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series 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() + aest, _ := time.LoadLocation("Australia/Sydney") + scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).Value + + if !getter.ValidateAndNotify(s, w, r) { + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + respond.NotFound(w, errors.New("series not found")) + return false, nil + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to propose a schedule", nil) + return false, nil + } + + _, err = db.ProposeSeriesSchedule(ctx, tx, seriesID, 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.ProposeSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.SuccessWithDelay(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} + +// AcceptSeriesSchedule handles POST /series/{series_id}/schedule/{schedule_id}/accept +func AcceptSeriesSchedule( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series 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) { + series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + respond.NotFound(w, errors.New("series not found")) + return false, nil + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to accept a schedule", nil) + return false, nil + } + + err = db.AcceptSeriesSchedule(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.AcceptSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Schedule Accepted", "The series time has been confirmed.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} + +// RejectSeriesSchedule handles POST /series/{series_id}/schedule/{schedule_id}/reject +func RejectSeriesSchedule( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series 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) { + series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + respond.NotFound(w, errors.New("series not found")) + return false, nil + } + + user := db.CurrentUser(ctx) + canSchedule, _, err := db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to reject a schedule", nil) + return false, nil + } + + err = db.RejectSeriesSchedule(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.RejectSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Schedule Rejected", "The proposed time has been rejected.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} + +// PostponeSeriesSchedule handles POST /series/{series_id}/schedule/postpone +func PostponeSeriesSchedule( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series 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) { + series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + respond.NotFound(w, errors.New("series not found")) + return false, nil + } + + user := db.CurrentUser(ctx) + canSchedule, _, err := db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to postpone a series", nil) + return false, nil + } + + err = db.PostponeSeriesSchedule(ctx, tx, seriesID, 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.PostponeSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Series Postponed", "The series has been postponed.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} + +// RescheduleSeriesHandler handles POST /series/{series_id}/schedule/reschedule +func RescheduleSeriesHandler( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series 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() + aest, _ := time.LoadLocation("Australia/Sydney") + scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).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) { + series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + respond.NotFound(w, errors.New("series not found")) + return false, nil + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to reschedule a series", nil) + return false, nil + } + + _, err = db.RescheduleSeriesSchedule(ctx, tx, seriesID, 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.RescheduleSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Series Rescheduled", "The new proposed time has been submitted.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} + +// WithdrawSeriesScheduleHandler handles POST /series/{series_id}/schedule/{schedule_id}/withdraw +func WithdrawSeriesScheduleHandler( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series 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) { + series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + respond.NotFound(w, errors.New("series not found")) + return false, nil + } + + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user) + if err != nil { + return false, errors.Wrap(err, "db.CanScheduleSeries") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to withdraw a proposal", nil) + return false, nil + } + + err = db.WithdrawSeriesSchedule(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.WithdrawSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Proposal Withdrawn", "Your proposed time has been withdrawn.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} + +// CancelSeriesScheduleHandler handles POST /series/{series_id}/schedule/cancel +// This is a moderator-only action that requires playoffs.manage permission. +func CancelSeriesScheduleHandler( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series 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.CancelSeriesSchedule(ctx, tx, seriesID, 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.CancelSeriesSchedule") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Schedule Cancelled", "The series schedule has been cancelled.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 3b39c2d..3bb0aae 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -329,6 +329,68 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.ForfeitFixture(s, conn)), }, + // Series detail page routes + { + Path: "/series/{series_id}", + Method: hws.MethodGET, + Handler: handlers.SeriesDetailPage(s, conn), + }, + { + Path: "/series/{series_id}/overview", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.SeriesDetailOverviewPage(s, conn), + }, + { + Path: "/series/{series_id}/preview", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.SeriesDetailPreviewPage(s, conn), + }, + { + Path: "/series/{series_id}/analysis", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.SeriesDetailAnalysisPage(s, conn), + }, + { + Path: "/series/{series_id}/scheduling", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.SeriesDetailSchedulePage(s, conn), + }, + // Series scheduling routes + { + Path: "/series/{series_id}/schedule", + Method: hws.MethodPOST, + Handler: handlers.ProposeSeriesSchedule(s, conn), + }, + { + Path: "/series/{series_id}/schedule/{schedule_id}/accept", + Method: hws.MethodPOST, + Handler: handlers.AcceptSeriesSchedule(s, conn), + }, + { + Path: "/series/{series_id}/schedule/{schedule_id}/reject", + Method: hws.MethodPOST, + Handler: handlers.RejectSeriesSchedule(s, conn), + }, + { + Path: "/series/{series_id}/schedule/{schedule_id}/withdraw", + Method: hws.MethodPOST, + Handler: handlers.WithdrawSeriesScheduleHandler(s, conn), + }, + { + Path: "/series/{series_id}/schedule/postpone", + Method: hws.MethodPOST, + Handler: handlers.PostponeSeriesSchedule(s, conn), + }, + { + Path: "/series/{series_id}/schedule/reschedule", + Method: hws.MethodPOST, + Handler: handlers.RescheduleSeriesHandler(s, conn), + }, + { + Path: "/series/{series_id}/schedule/cancel", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.CancelSeriesScheduleHandler(s, conn)), + }, } playerRoutes := []hws.Route{ diff --git a/internal/view/seasonsview/playoff_bracket.templ b/internal/view/seasonsview/playoff_bracket.templ index fe33d3c..b451156 100644 --- a/internal/view/seasonsview/playoff_bracket.templ +++ b/internal/view/seasonsview/playoff_bracket.templ @@ -44,6 +44,15 @@ templ PlayoffBracketView(season *db.Season, league *db.League, bracket *db.Playo + } // ────────────────────────────────────────────── @@ -189,11 +198,19 @@ templ bracket10to15(season *db.Season, league *db.League, bracket *db.PlayoffBra // ────────────────────────────────────────────── templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) { + {{ + hasTeams := series.Team1 != nil || series.Team2 != nil + seriesURL := fmt.Sprintf("/series/%d", series.ID) + }}
Bye — team advances automatically
+Teams not yet determined
+No time scheduled
++ @localtime(schedule.ScheduledTime, "date") +
++ @localtime(schedule.ScheduledTime, "time") +
++ @localtime(schedule.ScheduledTime, "date") +
++ @localtime(schedule.ScheduledTime, "time") +
+Awaiting confirmation
+{ *schedule.RescheduleReason }
+ } +No time confirmed
+No players on roster.
+No recent matches played
+ } else { +No recent matches played
+ } else { +No players on roster.
+ } else { +Waiting for Teams
++ Both teams must be determined before scheduling can begin. +
+No time scheduled
++ if canSchedule { + Use the form to propose a time for this series. + } else { + A team manager needs to propose a time for this series. + } +
++ Proposed: + @localtime(current.ScheduledTime, "datetime") +
++ Proposed by + { current.ProposedBy.Name } + — awaiting response from the other team +
+ if canSchedule && userTeamID != current.ProposedByTeamID { ++ Confirmed: + @localtime(current.ScheduledTime, "datetime") +
++ Both teams have agreed on this time. +
+Proposal Rejected
++ The proposed time was rejected. A new time needs to be proposed. +
+Schedule Cancelled
+ if current.RescheduleReason != nil { ++ { *current.RescheduleReason } +
+ } +Rescheduled
+ if current.RescheduleReason != nil { ++ Reason: { *current.RescheduleReason } +
+ } ++ A new time needs to be proposed. +
+Postponed
+ if current.RescheduleReason != nil { ++ Reason: { *current.RescheduleReason } +
+ } ++ A new time needs to be proposed. +
+Proposal Withdrawn
++ The proposed time was withdrawn. A new time needs to be proposed. +
++ This action will cancel the current series schedule. +
++ Only team managers can manage series scheduling. +
+No scheduling activity yet.
+ } else { +