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) + }}
diff --git a/internal/view/seasonsview/series_detail.templ b/internal/view/seasonsview/series_detail.templ new file mode 100644 index 0000000..fb4a911 --- /dev/null +++ b/internal/view/seasonsview/series_detail.templ @@ -0,0 +1,611 @@ +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 "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" +import "sort" +import "strings" + +// seriesTeamName returns a display name for a team in the series, or "TBD" if nil +func seriesTeamName(team *db.Team) string { + if team == nil { + return "TBD" + } + return team.Name +} + +// seriesTeamShortName returns a short name for a team in the series, or "TBD" if nil +func seriesTeamShortName(team *db.Team) string { + if team == nil { + return "TBD" + } + return team.ShortName +} + +// roundDisplayName converts a round slug to a human-readable name +func roundDisplayName(round string) string { + switch round { + case "upper_bracket": + return "Upper Bracket" + case "lower_bracket": + return "Lower Bracket" + case "upper_final": + return "Upper Final" + case "lower_final": + return "Lower Final" + case "quarter_final": + return "Quarter Final" + case "semi_final": + return "Semi Final" + case "elimination_final": + return "Elimination Final" + case "qualifying_final": + return "Qualifying Final" + case "preliminary_final": + return "Preliminary Final" + case "third_place": + return "Third Place Playoff" + case "grand_final": + return "Grand Final" + default: + return strings.ReplaceAll(round, "_", " ") + } +} + +// SeriesDetailLayout renders the series detail page layout with header and +// tab navigation. Tab content is rendered as children. +templ SeriesDetailLayout(activeTab string, series *db.PlayoffSeries, schedule *db.PlayoffSeriesSchedule) { + {{ + backURL := fmt.Sprintf("/seasons/%s/leagues/%s/finals", + series.Bracket.Season.ShortName, series.Bracket.League.ShortName) + isCompleted := series.Status == db.SeriesStatusCompleted + team1Name := seriesTeamName(series.Team1) + team2Name := seriesTeamName(series.Team2) + boLabel := fmt.Sprintf("BO%d", series.MatchesToWin*2-1) + }} + @baseview.Layout(fmt.Sprintf("%s — %s vs %s", series.Label, team1Name, team2Name)) { +
+ +
+
+
+
+
+

+ { team1Name } + vs + { team2Name } +

+
+
+ + { series.Label } + + + { boLabel } + + if series.Team1Seed != nil || series.Team2Seed != nil { + + if series.Team1Seed != nil && series.Team2Seed != nil { + Seed { fmt.Sprint(*series.Team1Seed) } vs { fmt.Sprint(*series.Team2Seed) } + } else if series.Team1Seed != nil { + Seed { fmt.Sprint(*series.Team1Seed) } + } else if series.Team2Seed != nil { + Seed { fmt.Sprint(*series.Team2Seed) } + } + + } + + { series.Bracket.Season.Name } — { series.Bracket.League.Name } + +
+
+ + Back to Bracket + +
+
+ + +
+ +
+ { children... } +
+
+ + } +} + +templ seriesTabItem(section string, label string, activeTab string, series *db.PlayoffSeries) { + {{ + isActive := section == activeTab + baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2" + activeClasses := "border-blue text-blue font-semibold" + inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2" + url := fmt.Sprintf("/series/%d/%s", series.ID, section) + }} +
  • + + { label } + +
  • +} + +// ==================== Full page wrappers (for GET requests / direct navigation) ==================== + +templ SeriesDetailOverviewPage( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + canSchedule bool, + userTeamID int, + rosters map[string][]*db.PlayerWithPlayStatus, +) { + @SeriesDetailLayout("overview", series, currentSchedule) { + @SeriesDetailOverviewContent(series, currentSchedule, canSchedule, userTeamID, rosters) + } +} + +templ SeriesDetailPreviewPage( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + rosters map[string][]*db.PlayerWithPlayStatus, + previewData *db.MatchPreviewData, +) { + @SeriesDetailLayout("preview", series, currentSchedule) { + @SeriesDetailPreviewContent(series, rosters, previewData) + } +} + +templ SeriesDetailAnalysisPage( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + rosters map[string][]*db.PlayerWithPlayStatus, + previewData *db.MatchPreviewData, +) { + @SeriesDetailLayout("analysis", series, currentSchedule) { + @SeriesDetailAnalysisContent(series, rosters, previewData) + } +} + +templ SeriesDetailSchedulePage( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + history []*db.PlayoffSeriesSchedule, + canSchedule bool, + userTeamID int, +) { + @SeriesDetailLayout("scheduling", series, currentSchedule) { + @SeriesDetailScheduleContent(series, currentSchedule, history, canSchedule, userTeamID) + } +} + +// ==================== Tab content components (for POST requests / HTMX swaps) ==================== + +templ SeriesDetailOverviewContent( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + canSchedule bool, + userTeamID int, + rosters map[string][]*db.PlayerWithPlayStatus, +) { + {{ + permCache := contexts.Permissions(ctx) + canManage := permCache.HasPermission(permissions.PlayoffsManage) + _ = canManage + }} + @seriesOverviewTab(series, currentSchedule, rosters, canSchedule, userTeamID) +} + +templ SeriesDetailPreviewContent( + series *db.PlayoffSeries, + rosters map[string][]*db.PlayerWithPlayStatus, + previewData *db.MatchPreviewData, +) { + @seriesMatchPreviewTab(series, rosters, previewData) +} + +templ SeriesDetailAnalysisContent( + series *db.PlayoffSeries, + rosters map[string][]*db.PlayerWithPlayStatus, + previewData *db.MatchPreviewData, +) { + @seriesMatchAnalysisTab(series, rosters, previewData) +} + +templ SeriesDetailScheduleContent( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + history []*db.PlayoffSeriesSchedule, + canSchedule bool, + userTeamID int, +) { + {{ + permCache := contexts.Permissions(ctx) + canManage := permCache.HasPermission(permissions.PlayoffsManage) + }} + @seriesScheduleTab(series, currentSchedule, history, canSchedule, canManage, userTeamID) +} + +// ==================== Overview Tab ==================== +templ seriesOverviewTab( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + rosters map[string][]*db.PlayerWithPlayStatus, + canSchedule bool, + userTeamID int, +) { +
    + +
    +
    + @seriesScoreDisplay(series) +
    +
    + @seriesScheduleSummary(series, currentSchedule) +
    +
    + + + if len(series.Matches) > 0 { + @seriesMatchList(series) + } + + + @seriesContextCard(series) + + +
    + if series.Team1 != nil { + @seriesTeamSection(series.Team1, rosters["team1"], series.Bracket.Season, series.Bracket.League) + } + if series.Team2 != nil { + @seriesTeamSection(series.Team2, rosters["team2"], series.Bracket.Season, series.Bracket.League) + } +
    +
    +} + +templ seriesScoreDisplay(series *db.PlayoffSeries) { + {{ + isCompleted := series.Status == db.SeriesStatusCompleted + team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID + team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID + isBye := series.Status == db.SeriesStatusBye + }} +
    +
    +

    Series Score

    +
    + @seriesStatusBadge(series.Status) + @seriesFormatBadge(series.MatchesToWin) +
    +
    +
    + if isBye { +
    +

    Bye — team advances automatically

    +
    + } else if series.Team1 == nil && series.Team2 == nil { +
    +

    Teams not yet determined

    +
    + } else { +
    +
    + if team1Won { + 🏆 + } + if series.Team1 != nil && series.Team1.Color != "" { + + { seriesTeamShortName(series.Team1) } + + } else { + + { seriesTeamShortName(series.Team1) } + + } + { fmt.Sprint(series.Team1Wins) } +
    +
    + + if isCompleted { + + FINAL + + } +
    +
    + { fmt.Sprint(series.Team2Wins) } + if series.Team2 != nil && series.Team2.Color != "" { + + { seriesTeamShortName(series.Team2) } + + } else { + + { seriesTeamShortName(series.Team2) } + + } + if team2Won { + 🏆 + } +
    +
    + } +
    +
    +} + +templ seriesScheduleSummary(series *db.PlayoffSeries, schedule *db.PlayoffSeriesSchedule) { + {{ + isCompleted := series.Status == db.SeriesStatusCompleted + }} +
    +
    +

    Schedule

    +
    +
    + if schedule == nil { +
    +

    No time scheduled

    +
    + } else if schedule.Status == db.ScheduleStatusAccepted && schedule.ScheduledTime != nil { +
    + if isCompleted { + + Played + + } else { + + Confirmed + + } +

    + @localtime(schedule.ScheduledTime, "date") +

    +

    + @localtime(schedule.ScheduledTime, "time") +

    +
    + } else if schedule.Status == db.ScheduleStatusPending && schedule.ScheduledTime != nil { +
    + + Proposed + +

    + @localtime(schedule.ScheduledTime, "date") +

    +

    + @localtime(schedule.ScheduledTime, "time") +

    +

    Awaiting confirmation

    +
    + } else if schedule.Status == db.ScheduleStatusCancelled { +
    + + Cancelled + + if schedule.RescheduleReason != nil { +

    { *schedule.RescheduleReason }

    + } +
    + } else { +
    +

    No time confirmed

    +
    + } +
    +
    +} + +templ seriesMatchList(series *db.PlayoffSeries) { +
    +
    +

    Matches

    +
    +
    + for _, match := range series.Matches { + @seriesMatchRow(series, match) + } +
    +
    +} + +templ seriesMatchRow(series *db.PlayoffSeries, match *db.PlayoffMatch) { + {{ + matchLabel := fmt.Sprintf("Game %d", match.MatchNumber) + isPending := match.Status == "pending" + isCompleted := match.Status == "completed" + hasFixture := match.FixtureID != nil + _ = hasFixture + }} +
    +
    + { matchLabel } + if isPending { + + Pending + + } else if isCompleted { + + Complete + + } else { + + { match.Status } + + } +
    + if match.FixtureID != nil { + + View Details + + } +
    +} + +templ seriesContextCard(series *db.PlayoffSeries) { + {{ + // Determine advancement info + winnerAdvances := "" + loserAdvances := "" + + if series.WinnerNextID != nil { + // Look through bracket series for the target + if series.Bracket != nil { + for _, s := range series.Bracket.Series { + if s.ID == *series.WinnerNextID { + winnerAdvances = s.Label + break + } + } + } + if winnerAdvances == "" { + winnerAdvances = "next round" + } + } + if series.LoserNextID != nil { + if series.Bracket != nil { + for _, s := range series.Bracket.Series { + if s.ID == *series.LoserNextID { + loserAdvances = s.Label + break + } + } + } + if loserAdvances == "" { + loserAdvances = "next round" + } + } + }} +
    +
    +

    Series Info

    +
    +
    +
    + Round + { roundDisplayName(series.Round) } +
    +
    + Format + Best of { fmt.Sprint(series.MatchesToWin*2 - 1) } (first to { fmt.Sprint(series.MatchesToWin) }) +
    + if series.Team1Seed != nil && series.Team2Seed != nil { +
    + Seeding + + { ordinal(*series.Team1Seed) } seed vs { ordinal(*series.Team2Seed) } seed + +
    + } + if winnerAdvances != "" { +
    + Winner → + { winnerAdvances } +
    + } else { +
    + Winner → + Champion +
    + } + if loserAdvances != "" { +
    + Loser → + { loserAdvances } +
    + } else if series.WinnerNextID != nil { +
    + Loser → + Eliminated +
    + } +
    +
    +} + +templ seriesTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, season *db.Season, league *db.League) { + {{ + // Sort with managers first + sort.SliceStable(players, func(i, j int) bool { + return players[i].IsManager && !players[j].IsManager + }) + }} +
    +
    +

    + @links.TeamNameLinkInSeason(team, season, league) +

    + if team.Color != "" { + + } +
    + if len(players) == 0 { +
    +

    No players on roster.

    +
    + } else { +
    +
    + for _, p := range players { +
    + + @links.PlayerLink(p.Player) + + if p.IsManager { + + ★ Manager + + } + if p.IsFreeAgent { + + FREE AGENT + + } +
    + } +
    +
    + } +
    +} diff --git a/internal/view/seasonsview/series_match_analysis.templ b/internal/view/seasonsview/series_match_analysis.templ new file mode 100644 index 0000000..799d93a --- /dev/null +++ b/internal/view/seasonsview/series_match_analysis.templ @@ -0,0 +1,251 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" + +// seriesMatchAnalysisTab renders the full Match Analysis tab for completed series. +// Shows final series score, individual match results, aggregated team stats, +// top performers, and league context. +templ seriesMatchAnalysisTab( + series *db.PlayoffSeries, + rosters map[string][]*db.PlayerWithPlayStatus, + preview *db.MatchPreviewData, +) { +
    + + @seriesAnalysisScoreHeader(series) + + + if len(series.Matches) > 0 { + @seriesAnalysisMatchResults(series) + } + + + if preview != nil { + @seriesAnalysisLeagueContext(series, preview) + } +
    +} + +// seriesAnalysisScoreHeader renders the final series score in a prominent display. +templ seriesAnalysisScoreHeader(series *db.PlayoffSeries) { + {{ + team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID + team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID + }} +
    +
    +

    Final Series Score

    +
    +
    +
    + +
    + if series.Team1 != nil && series.Team1.Color != "" { +
    + } +

    + if series.Team1 != nil { + @links.TeamNameLinkInSeason(series.Team1, series.Bracket.Season, series.Bracket.League) + } else { + TBD + } +

    + + { fmt.Sprint(series.Team1Wins) } + + if team1Won { + Winner + } +
    + +
    + +
    + +
    + if series.Team2 != nil && series.Team2.Color != "" { +
    + } +

    + if series.Team2 != nil { + @links.TeamNameLinkInSeason(series.Team2, series.Bracket.Season, series.Bracket.League) + } else { + TBD + } +

    + + { fmt.Sprint(series.Team2Wins) } + + if team2Won { + Winner + } +
    +
    +
    +
    +} + +// seriesAnalysisMatchResults shows individual match results as a compact list. +templ seriesAnalysisMatchResults(series *db.PlayoffSeries) { +
    +
    +

    Match Results

    +
    +
    + for _, match := range series.Matches { + @seriesAnalysisMatchRow(series, match) + } +
    +
    +} + +templ seriesAnalysisMatchRow(series *db.PlayoffSeries, match *db.PlayoffMatch) { + {{ + matchLabel := fmt.Sprintf("Game %d", match.MatchNumber) + isCompleted := match.Status == "completed" + }} +
    +
    + { matchLabel } + if isCompleted { + + Complete + + } else { + + { match.Status } + + } +
    + if match.FixtureID != nil { + + View Details + + } +
    +} + +// seriesAnalysisLeagueContext shows how the teams sit in the league standings. +templ seriesAnalysisLeagueContext(series *db.PlayoffSeries, preview *db.MatchPreviewData) { +
    +
    +

    League Context

    +
    +
    + +
    +
    +
    + if series.Team1 != nil && series.Team1.Color != "" { + + } + { seriesTeamShortName(series.Team1) } +
    +
    +
    +
    +
    + { seriesTeamShortName(series.Team2) } + if series.Team2 != nil && series.Team2.Color != "" { + + } +
    +
    +
    +
    + {{ + homePos := ordinal(preview.HomePosition) + awayPos := ordinal(preview.AwayPosition) + if preview.HomePosition == 0 { + homePos = "N/A" + } + if preview.AwayPosition == 0 { + awayPos = "N/A" + } + }} + @previewStatRow( + homePos, + "Position", + awayPos, + preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition, + preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Points), + "Points", + fmt.Sprint(preview.AwayRecord.Points), + preview.HomeRecord.Points > preview.AwayRecord.Points, + preview.AwayRecord.Points > preview.HomeRecord.Points, + ) + @previewStatRow( + fmt.Sprintf("%d-%d-%d-%d", + preview.HomeRecord.Wins, + preview.HomeRecord.OvertimeWins, + preview.HomeRecord.OvertimeLosses, + preview.HomeRecord.Losses, + ), + "Record", + fmt.Sprintf("%d-%d-%d-%d", + preview.AwayRecord.Wins, + preview.AwayRecord.OvertimeWins, + preview.AwayRecord.OvertimeLosses, + preview.AwayRecord.Losses, + ), + false, + false, + ) + {{ + homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst + awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst + }} + @previewStatRow( + fmt.Sprintf("%+d", homeDiff), + "Goal Diff", + fmt.Sprintf("%+d", awayDiff), + homeDiff > awayDiff, + awayDiff > homeDiff, + ) + + if len(preview.HomeRecentGames) > 0 || len(preview.AwayRecentGames) > 0 { +
    +
    +
    + for _, g := range preview.HomeRecentGames { + @gameOutcomeIcon(g) + } +
    +
    +
    + Form +
    +
    +
    + for _, g := range preview.AwayRecentGames { + @gameOutcomeIcon(g) + } +
    +
    +
    + } +
    +
    +
    +} diff --git a/internal/view/seasonsview/series_match_preview.templ b/internal/view/seasonsview/series_match_preview.templ new file mode 100644 index 0000000..052151b --- /dev/null +++ b/internal/view/seasonsview/series_match_preview.templ @@ -0,0 +1,319 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" +import "sort" + +// seriesMatchPreviewTab renders the full Match Preview tab for upcoming series. +// Shows team standings comparison, recent form, and full rosters side-by-side. +templ seriesMatchPreviewTab( + series *db.PlayoffSeries, + rosters map[string][]*db.PlayerWithPlayStatus, + preview *db.MatchPreviewData, +) { +
    + + if preview != nil { + @seriesPreviewHeader(series, preview) + } + + + if preview != nil { + @seriesPreviewFormGuide(series, preview) + } + + + @seriesPreviewRosters(series, rosters) +
    +} + +// seriesPreviewHeader renders the broadcast-style team comparison with standings. +templ seriesPreviewHeader(series *db.PlayoffSeries, preview *db.MatchPreviewData) { +
    +
    +

    Team Comparison

    +
    +
    + +
    + +
    + if series.Team1 != nil && series.Team1.Color != "" { +
    + } +

    + if series.Team1 != nil { + @links.TeamNameLinkInSeason(series.Team1, series.Bracket.Season, series.Bracket.League) + } else { + TBD + } +

    + if series.Team1 != nil { + { series.Team1.ShortName } + } +
    + +
    + VS +
    + +
    + if series.Team2 != nil && series.Team2.Color != "" { +
    + } +

    + if series.Team2 != nil { + @links.TeamNameLinkInSeason(series.Team2, series.Bracket.Season, series.Bracket.League) + } else { + TBD + } +

    + if series.Team2 != nil { + { series.Team2.ShortName } + } +
    +
    + + {{ + homePos := ordinal(preview.HomePosition) + awayPos := ordinal(preview.AwayPosition) + if preview.HomePosition == 0 { + homePos = "N/A" + } + if preview.AwayPosition == 0 { + awayPos = "N/A" + } + }} +
    + @previewStatRow( + homePos, + "Position", + awayPos, + preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition, + preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Points), + "Points", + fmt.Sprint(preview.AwayRecord.Points), + preview.HomeRecord.Points > preview.AwayRecord.Points, + preview.AwayRecord.Points > preview.HomeRecord.Points, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Played), + "Played", + fmt.Sprint(preview.AwayRecord.Played), + false, + false, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Wins), + "Wins", + fmt.Sprint(preview.AwayRecord.Wins), + preview.HomeRecord.Wins > preview.AwayRecord.Wins, + preview.AwayRecord.Wins > preview.HomeRecord.Wins, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.OvertimeWins), + "OT Wins", + fmt.Sprint(preview.AwayRecord.OvertimeWins), + preview.HomeRecord.OvertimeWins > preview.AwayRecord.OvertimeWins, + preview.AwayRecord.OvertimeWins > preview.HomeRecord.OvertimeWins, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.OvertimeLosses), + "OT Losses", + fmt.Sprint(preview.AwayRecord.OvertimeLosses), + preview.HomeRecord.OvertimeLosses < preview.AwayRecord.OvertimeLosses, + preview.AwayRecord.OvertimeLosses < preview.HomeRecord.OvertimeLosses, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Losses), + "Losses", + fmt.Sprint(preview.AwayRecord.Losses), + preview.HomeRecord.Losses < preview.AwayRecord.Losses, + preview.AwayRecord.Losses < preview.HomeRecord.Losses, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.GoalsFor), + "Goals For", + fmt.Sprint(preview.AwayRecord.GoalsFor), + preview.HomeRecord.GoalsFor > preview.AwayRecord.GoalsFor, + preview.AwayRecord.GoalsFor > preview.HomeRecord.GoalsFor, + ) + @previewStatRow( + fmt.Sprint(preview.HomeRecord.GoalsAgainst), + "Goals Against", + fmt.Sprint(preview.AwayRecord.GoalsAgainst), + preview.HomeRecord.GoalsAgainst < preview.AwayRecord.GoalsAgainst, + preview.AwayRecord.GoalsAgainst < preview.HomeRecord.GoalsAgainst, + ) + {{ + homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst + awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst + }} + @previewStatRow( + fmt.Sprintf("%+d", homeDiff), + "Goal Diff", + fmt.Sprintf("%+d", awayDiff), + homeDiff > awayDiff, + awayDiff > homeDiff, + ) +
    +
    +
    +} + +// seriesPreviewFormGuide renders recent form for each team. +templ seriesPreviewFormGuide(series *db.PlayoffSeries, preview *db.MatchPreviewData) { +
    +
    +

    Recent Form

    +
    +
    +
    + +
    +
    + if series.Team1 != nil && series.Team1.Color != "" { + + } +

    { seriesTeamName(series.Team1) }

    +
    + if len(preview.HomeRecentGames) == 0 { +

    No recent matches played

    + } else { +
    + for _, g := range preview.HomeRecentGames { + @gameOutcomeIcon(g) + } +
    +
    + for i := len(preview.HomeRecentGames) - 1; i >= 0; i-- { + @recentGameRow(preview.HomeRecentGames[i]) + } +
    + } +
    + +
    +
    + if series.Team2 != nil && series.Team2.Color != "" { + + } +

    { seriesTeamName(series.Team2) }

    +
    + if len(preview.AwayRecentGames) == 0 { +

    No recent matches played

    + } else { +
    + for _, g := range preview.AwayRecentGames { + @gameOutcomeIcon(g) + } +
    +
    + for i := len(preview.AwayRecentGames) - 1; i >= 0; i-- { + @recentGameRow(preview.AwayRecentGames[i]) + } +
    + } +
    +
    +
    +
    +} + +// seriesPreviewRosters renders team rosters side-by-side. +templ seriesPreviewRosters(series *db.PlayoffSeries, rosters map[string][]*db.PlayerWithPlayStatus) { +
    +
    +

    Team Rosters

    +
    +
    +
    + if series.Team1 != nil { + @seriesPreviewRosterColumn(series.Team1, rosters["team1"], series.Bracket.Season, series.Bracket.League) + } + if series.Team2 != nil { + @seriesPreviewRosterColumn(series.Team2, rosters["team2"], series.Bracket.Season, series.Bracket.League) + } +
    +
    +
    +} + +templ seriesPreviewRosterColumn( + team *db.Team, + players []*db.PlayerWithPlayStatus, + season *db.Season, + league *db.League, +) { + {{ + var managers []*db.PlayerWithPlayStatus + var roster []*db.PlayerWithPlayStatus + for _, p := range players { + if p.IsManager { + managers = append(managers, p) + } else { + roster = append(roster, p) + } + } + sort.Slice(roster, func(i, j int) bool { + return roster[i].Player.DisplayName() < roster[j].Player.DisplayName() + }) + }} +
    +
    +
    + if team.Color != "" { + + } +

    + @links.TeamNameLinkInSeason(team, season, league) +

    +
    + + { fmt.Sprint(len(players)) } players + +
    + if len(players) == 0 { +

    No players on roster.

    + } else { +
    + for _, p := range managers { +
    + + ★ + + + @links.PlayerLink(p.Player) + +
    + } + for _, p := range roster { +
    + + @links.PlayerLink(p.Player) + +
    + } +
    + } +
    +} diff --git a/internal/view/seasonsview/series_schedule.templ b/internal/view/seasonsview/series_schedule.templ new file mode 100644 index 0000000..724451c --- /dev/null +++ b/internal/view/seasonsview/series_schedule.templ @@ -0,0 +1,504 @@ +package seasonsview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" + +// ==================== Schedule Tab ==================== +templ seriesScheduleTab( + series *db.PlayoffSeries, + currentSchedule *db.PlayoffSeriesSchedule, + history []*db.PlayoffSeriesSchedule, + canSchedule bool, + canManage bool, + userTeamID int, +) { +
    + @seriesScheduleStatus(series, currentSchedule, canSchedule, canManage, userTeamID) + @seriesScheduleActions(series, currentSchedule, canSchedule, canManage, userTeamID) + @seriesScheduleHistory(series, history) +
    +} + +templ seriesScheduleStatus( + series *db.PlayoffSeries, + current *db.PlayoffSeriesSchedule, + canSchedule bool, + canManage bool, + userTeamID int, +) { + {{ + bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil + }} +
    +
    +

    Schedule Status

    +
    +
    + if !bothTeamsAssigned { +
    +
    +

    Waiting for Teams

    +

    + Both teams must be determined before scheduling can begin. +

    +
    + } else if current == nil { +
    +
    📅
    +

    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. + } +

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

    + Proposed: + @localtime(current.ScheduledTime, "datetime") +

    +

    + Proposed by + { current.ProposedBy.Name } + — awaiting response from the other team +

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

    + Confirmed: + @localtime(current.ScheduledTime, "datetime") +

    +

    + Both teams have agreed on this time. +

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

    Proposal Rejected

    +

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

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

    Schedule Cancelled

    + if current.RescheduleReason != nil { +

    + { *current.RescheduleReason } +

    + } +
    + } else if current.Status == db.ScheduleStatusRescheduled { +
    +
    🔄
    +

    Rescheduled

    + if current.RescheduleReason != nil { +

    + Reason: { *current.RescheduleReason } +

    + } +

    + A new time needs to be proposed. +

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

    Postponed

    + if current.RescheduleReason != nil { +

    + Reason: { *current.RescheduleReason } +

    + } +

    + A new time needs to be proposed. +

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

    Proposal Withdrawn

    +

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

    +
    + } +
    +
    +} + +templ seriesScheduleActions( + series *db.PlayoffSeries, + current *db.PlayoffSeriesSchedule, + canSchedule bool, + canManage bool, + userTeamID int, +) { + {{ + bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil + + showPropose := false + showReschedule := false + showPostpone := false + showCancel := false + + if bothTeamsAssigned && 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 bothTeamsAssigned && canManage && current != nil && !current.Status.IsTerminal() { + showCancel = true + } + }} + if showPropose || showReschedule || showPostpone || showCancel { +
    + + if showPropose { +
    +
    +

    Propose Time

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

    Reschedule

    +
    +
    +
    +
    + + +
    +
    + + @seriesRescheduleReasonSelect(series) +
    + +
    +
    +
    + } + + if showPostpone { +
    +
    +

    Postpone

    +
    +
    +
    +
    + + @seriesRescheduleReasonSelect(series) +
    + +
    +
    +
    + } + + if showCancel { +
    +
    +

    Cancel Schedule

    +
    +
    +

    + This action will cancel the current series schedule. +

    +
    +
    + + +
    + +
    +
    +
    + } +
    + } else { + if !canSchedule && !canManage { +
    +

    + Only team managers can manage series scheduling. +

    +
    + } + } +} + +templ seriesRescheduleReasonSelect(series *db.PlayoffSeries) { + {{ + team1Name := seriesTeamName(series.Team1) + team2Name := seriesTeamName(series.Team2) + }} + +} + +templ seriesScheduleHistory(series *db.PlayoffSeries, history []*db.PlayoffSeriesSchedule) { +
    +
    +

    Schedule History

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

    No scheduling activity yet.

    + } else { +
    + for i := len(history) - 1; i >= 0; i-- { + @seriesScheduleHistoryItem(history[i], i == len(history)-1) + } +
    + } +
    +
    +} + +templ seriesScheduleHistoryItem(schedule *db.PlayoffSeriesSchedule, isCurrent bool) { + {{ + statusColor := "text-subtext0" + statusBg := "bg-surface1" + statusLabel := string(schedule.Status) + switch schedule.Status { + case db.ScheduleStatusPending: + statusColor = "text-blue" + statusBg = "bg-blue/20" + statusLabel = "Pending" + case db.ScheduleStatusAccepted: + statusColor = "text-green" + statusBg = "bg-green/20" + statusLabel = "Accepted" + case db.ScheduleStatusRejected: + statusColor = "text-red" + statusBg = "bg-red/20" + statusLabel = "Rejected" + case db.ScheduleStatusRescheduled: + statusColor = "text-yellow" + statusBg = "bg-yellow/20" + statusLabel = "Rescheduled" + case db.ScheduleStatusPostponed: + statusColor = "text-peach" + statusBg = "bg-peach/20" + statusLabel = "Postponed" + case db.ScheduleStatusCancelled: + statusColor = "text-red" + statusBg = "bg-red/20" + statusLabel = "Cancelled" + case db.ScheduleStatusWithdrawn: + statusColor = "text-subtext0" + statusBg = "bg-surface1" + statusLabel = "Withdrawn" + } + }} +
    +
    +
    + if isCurrent { + + CURRENT + + } + + { statusLabel } + +
    + + @localtimeUnix(schedule.CreatedAt, "histdate") + +
    +
    +
    + Proposed by: + { schedule.ProposedBy.Name } +
    + if schedule.ScheduledTime != nil { +
    + Time: + + @localtime(schedule.ScheduledTime, "datetime") + +
    + } else { +
    + Time: + No time set +
    + } + if schedule.RescheduleReason != nil { +
    + Reason: + { *schedule.RescheduleReason } +
    + } +
    +
    +}