series overview added
This commit is contained in:
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -324,3 +324,224 @@ func AutoForfeitUnplayedFixtures(
|
|||||||
|
|
||||||
return len(unplayed), nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
370
internal/db/playoff_schedule.go
Normal file
370
internal/db/playoff_schedule.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -553,6 +553,9 @@
|
|||||||
.w-14 {
|
.w-14 {
|
||||||
width: calc(var(--spacing) * 14);
|
width: calc(var(--spacing) * 14);
|
||||||
}
|
}
|
||||||
|
.w-16 {
|
||||||
|
width: calc(var(--spacing) * 16);
|
||||||
|
}
|
||||||
.w-20 {
|
.w-20 {
|
||||||
width: calc(var(--spacing) * 20);
|
width: calc(var(--spacing) * 20);
|
||||||
}
|
}
|
||||||
|
|||||||
306
internal/handlers/series_detail.go
Normal file
306
internal/handlers/series_detail.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
410
internal/handlers/series_schedule.go
Normal file
410
internal/handlers/series_schedule.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -329,6 +329,68 @@ func addRoutes(
|
|||||||
Method: hws.MethodPOST,
|
Method: hws.MethodPOST,
|
||||||
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.ForfeitFixture(s, conn)),
|
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{
|
playerRoutes := []hws.Route{
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ templ PlayoffBracketView(season *db.Season, league *db.League, bracket *db.Playo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/js/bracket-lines.js"></script>
|
<script src="/static/js/bracket-lines.js"></script>
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('[data-series-url]').forEach(function(card) {
|
||||||
|
card.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('a')) {
|
||||||
|
window.location.href = card.getAttribute('data-series-url');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@@ -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) {
|
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)
|
||||||
|
}}
|
||||||
<div
|
<div
|
||||||
data-series={ fmt.Sprint(series.SeriesNumber) }
|
data-series={ fmt.Sprint(series.SeriesNumber) }
|
||||||
|
if hasTeams {
|
||||||
|
data-series-url={ seriesURL }
|
||||||
|
}
|
||||||
class={ "bg-surface0 border rounded-lg overflow-hidden",
|
class={ "bg-surface0 border rounded-lg overflow-hidden",
|
||||||
templ.KV("border-blue/50", series.Status == db.SeriesStatusInProgress),
|
templ.KV("border-blue/50", series.Status == db.SeriesStatusInProgress),
|
||||||
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress) }
|
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress),
|
||||||
|
templ.KV("hover:bg-surface1 hover:cursor-pointer transition", hasTeams) }
|
||||||
>
|
>
|
||||||
<!-- Series Header -->
|
<!-- Series Header -->
|
||||||
<div class="bg-mantle px-3 py-1.5 flex items-center justify-between border-b border-surface1">
|
<div class="bg-mantle px-3 py-1.5 flex items-center justify-between border-b border-surface1">
|
||||||
|
|||||||
611
internal/view/seasonsview/series_detail.templ
Normal file
611
internal/view/seasonsview/series_detail.templ
Normal file
@@ -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)) {
|
||||||
|
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<h1 class="text-3xl font-bold text-text">
|
||||||
|
{ team1Name }
|
||||||
|
<span class="text-subtext0 font-normal">vs</span>
|
||||||
|
{ team2Name }
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||||
|
{ series.Label }
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-0.5 bg-surface1 text-subtext0 rounded text-xs font-mono">
|
||||||
|
{ boLabel }
|
||||||
|
</span>
|
||||||
|
if series.Team1Seed != nil || series.Team2Seed != nil {
|
||||||
|
<span class="px-2 py-0.5 bg-surface1 text-subtext0 rounded text-xs font-mono">
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="text-subtext1 text-sm">
|
||||||
|
{ series.Bracket.Season.Name } — { series.Bracket.League.Name }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(backURL) }
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
|
bg-surface1 hover:bg-surface2 text-text transition"
|
||||||
|
>
|
||||||
|
Back to Bracket
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<nav class="bg-surface0 border-b border-surface1" data-tab-nav="series-detail-content">
|
||||||
|
<ul class="flex flex-wrap">
|
||||||
|
@seriesTabItem("overview", "Overview", activeTab, series)
|
||||||
|
if isCompleted {
|
||||||
|
@seriesTabItem("analysis", "Match Analysis", activeTab, series)
|
||||||
|
} else {
|
||||||
|
@seriesTabItem("preview", "Match Preview", activeTab, series)
|
||||||
|
@seriesTabItem("scheduling", "Schedule", activeTab, series)
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<!-- Content Area -->
|
||||||
|
<main id="series-detail-content">
|
||||||
|
{ children... }
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/tabs.js" defer></script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}}
|
||||||
|
<li class="inline-block">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(url) }
|
||||||
|
hx-post={ url }
|
||||||
|
hx-target="#series-detail-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-push-url={ url }
|
||||||
|
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||||||
|
>
|
||||||
|
{ label }
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 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,
|
||||||
|
) {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Series Score + Schedule Row -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
@seriesScoreDisplay(series)
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
@seriesScheduleSummary(series, currentSchedule)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Match List -->
|
||||||
|
if len(series.Matches) > 0 {
|
||||||
|
@seriesMatchList(series)
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Series Context -->
|
||||||
|
@seriesContextCard(series)
|
||||||
|
|
||||||
|
<!-- Team Rosters -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-text">Series Score</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@seriesStatusBadge(series.Status)
|
||||||
|
@seriesFormatBadge(series.MatchesToWin)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
if isBye {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<p class="text-lg text-subtext0">Bye — team advances automatically</p>
|
||||||
|
</div>
|
||||||
|
} else if series.Team1 == nil && series.Team2 == nil {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<p class="text-lg text-subtext0">Teams not yet determined</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="flex items-center justify-center gap-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
if team1Won {
|
||||||
|
<span class="text-2xl">🏆</span>
|
||||||
|
}
|
||||||
|
if series.Team1 != nil && series.Team1.Color != "" {
|
||||||
|
<span
|
||||||
|
class="px-2.5 py-1 rounded text-sm font-bold"
|
||||||
|
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
||||||
|
series.Team1.Color, series.Team1.Color, series.Team1.Color) }
|
||||||
|
>
|
||||||
|
{ seriesTeamShortName(series.Team1) }
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||||||
|
{ seriesTeamShortName(series.Team1) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="text-4xl font-bold text-text">{ fmt.Sprint(series.Team1Wins) }</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span class="text-4xl text-subtext0 font-light leading-none">–</span>
|
||||||
|
if isCompleted {
|
||||||
|
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs font-semibold mt-1">
|
||||||
|
FINAL
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-4xl font-bold text-text">{ fmt.Sprint(series.Team2Wins) }</span>
|
||||||
|
if series.Team2 != nil && series.Team2.Color != "" {
|
||||||
|
<span
|
||||||
|
class="px-2.5 py-1 rounded text-sm font-bold"
|
||||||
|
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
||||||
|
series.Team2.Color, series.Team2.Color, series.Team2.Color) }
|
||||||
|
>
|
||||||
|
{ seriesTeamShortName(series.Team2) }
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||||||
|
{ seriesTeamShortName(series.Team2) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
if team2Won {
|
||||||
|
<span class="text-2xl">🏆</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesScheduleSummary(series *db.PlayoffSeries, schedule *db.PlayoffSeriesSchedule) {
|
||||||
|
{{
|
||||||
|
isCompleted := series.Status == db.SeriesStatusCompleted
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden h-full">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Schedule</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 flex flex-col justify-center h-[calc(100%-3rem)]">
|
||||||
|
if schedule == nil {
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-subtext1 text-sm">No time scheduled</p>
|
||||||
|
</div>
|
||||||
|
} else if schedule.Status == db.ScheduleStatusAccepted && schedule.ScheduledTime != nil {
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
if isCompleted {
|
||||||
|
<span class="px-2 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium">
|
||||||
|
Played
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
||||||
|
Confirmed
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<p class="text-text font-medium">
|
||||||
|
@localtime(schedule.ScheduledTime, "date")
|
||||||
|
</p>
|
||||||
|
<p class="text-text text-lg font-bold">
|
||||||
|
@localtime(schedule.ScheduledTime, "time")
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if schedule.Status == db.ScheduleStatusPending && schedule.ScheduledTime != nil {
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||||
|
Proposed
|
||||||
|
</span>
|
||||||
|
<p class="text-text font-medium">
|
||||||
|
@localtime(schedule.ScheduledTime, "date")
|
||||||
|
</p>
|
||||||
|
<p class="text-text text-lg font-bold">
|
||||||
|
@localtime(schedule.ScheduledTime, "time")
|
||||||
|
</p>
|
||||||
|
<p class="text-subtext1 text-xs">Awaiting confirmation</p>
|
||||||
|
</div>
|
||||||
|
} else if schedule.Status == db.ScheduleStatusCancelled {
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
|
||||||
|
Cancelled
|
||||||
|
</span>
|
||||||
|
if schedule.RescheduleReason != nil {
|
||||||
|
<p class="text-subtext1 text-xs">{ *schedule.RescheduleReason }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-subtext1 text-sm">No time confirmed</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesMatchList(series *db.PlayoffSeries) {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Matches</h2>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-surface1">
|
||||||
|
for _, match := range series.Matches {
|
||||||
|
@seriesMatchRow(series, match)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}}
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 hover:bg-surface0 transition-colors">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm font-medium text-text">{ matchLabel }</span>
|
||||||
|
if isPending {
|
||||||
|
<span class="px-1.5 py-0.5 bg-surface1 text-subtext0 rounded text-xs">
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
} else if isCompleted {
|
||||||
|
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs">
|
||||||
|
Complete
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs">
|
||||||
|
{ match.Status }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if match.FixtureID != nil {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/overview", *match.FixtureID)) }
|
||||||
|
class="px-3 py-1 bg-surface1 hover:bg-surface2 text-text rounded text-xs
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Series Info</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Round</span>
|
||||||
|
<span class="text-sm font-medium text-text">{ roundDisplayName(series.Round) }</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Format</span>
|
||||||
|
<span class="text-sm font-medium text-text">Best of { fmt.Sprint(series.MatchesToWin*2 - 1) } (first to { fmt.Sprint(series.MatchesToWin) })</span>
|
||||||
|
</div>
|
||||||
|
if series.Team1Seed != nil && series.Team2Seed != nil {
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Seeding</span>
|
||||||
|
<span class="text-sm font-medium text-text">
|
||||||
|
{ ordinal(*series.Team1Seed) } seed vs { ordinal(*series.Team2Seed) } seed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if winnerAdvances != "" {
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Winner →</span>
|
||||||
|
<span class="text-sm font-medium text-green">{ winnerAdvances }</span>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Winner →</span>
|
||||||
|
<span class="text-sm font-medium text-yellow">Champion</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if loserAdvances != "" {
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Loser →</span>
|
||||||
|
<span class="text-sm font-medium text-peach">{ loserAdvances }</span>
|
||||||
|
</div>
|
||||||
|
} else if series.WinnerNextID != nil {
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-subtext0 w-24 shrink-0">Loser →</span>
|
||||||
|
<span class="text-sm font-medium text-red">Eliminated</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||||
|
<h3 class="text-md font-bold">
|
||||||
|
@links.TeamNameLinkInSeason(team, season, league)
|
||||||
|
</h3>
|
||||||
|
if team.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-4 h-4 rounded-full border border-surface1"
|
||||||
|
style={ fmt.Sprintf("background-color: %s", team.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if len(players) == 0 {
|
||||||
|
<div class="p-4">
|
||||||
|
<p class="text-subtext1 text-sm text-center py-2">No players on roster.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
for _, p := range players {
|
||||||
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface0 transition">
|
||||||
|
<span class="text-sm">
|
||||||
|
@links.PlayerLink(p.Player)
|
||||||
|
</span>
|
||||||
|
if p.IsManager {
|
||||||
|
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||||
|
★ Manager
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
if p.IsFreeAgent {
|
||||||
|
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||||
|
FREE AGENT
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
251
internal/view/seasonsview/series_match_analysis.templ
Normal file
251
internal/view/seasonsview/series_match_analysis.templ
Normal file
@@ -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,
|
||||||
|
) {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Final Series Score -->
|
||||||
|
@seriesAnalysisScoreHeader(series)
|
||||||
|
|
||||||
|
<!-- Individual Match Results -->
|
||||||
|
if len(series.Matches) > 0 {
|
||||||
|
@seriesAnalysisMatchResults(series)
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- League Context (from preview data) -->
|
||||||
|
if preview != nil {
|
||||||
|
@seriesAnalysisLeagueContext(series, preview)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Final Series Score</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-center gap-6 sm:gap-10">
|
||||||
|
<!-- Team 1 -->
|
||||||
|
<div class="flex flex-col items-center text-center flex-1">
|
||||||
|
if series.Team1 != nil && series.Team1.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
|
||||||
|
if series.Team1 != nil {
|
||||||
|
@links.TeamNameLinkInSeason(series.Team1, series.Bracket.Season, series.Bracket.League)
|
||||||
|
} else {
|
||||||
|
TBD
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", team1Won), templ.KV("text-text", !team1Won) }>
|
||||||
|
{ fmt.Sprint(series.Team1Wins) }
|
||||||
|
</span>
|
||||||
|
if team1Won {
|
||||||
|
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="flex flex-col items-center shrink-0">
|
||||||
|
<span class="text-4xl text-subtext0 font-light">–</span>
|
||||||
|
</div>
|
||||||
|
<!-- Team 2 -->
|
||||||
|
<div class="flex flex-col items-center text-center flex-1">
|
||||||
|
if series.Team2 != nil && series.Team2.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
|
||||||
|
if series.Team2 != nil {
|
||||||
|
@links.TeamNameLinkInSeason(series.Team2, series.Bracket.Season, series.Bracket.League)
|
||||||
|
} else {
|
||||||
|
TBD
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", team2Won), templ.KV("text-text", !team2Won) }>
|
||||||
|
{ fmt.Sprint(series.Team2Wins) }
|
||||||
|
</span>
|
||||||
|
if team2Won {
|
||||||
|
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesAnalysisMatchResults shows individual match results as a compact list.
|
||||||
|
templ seriesAnalysisMatchResults(series *db.PlayoffSeries) {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Match Results</h2>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-surface1">
|
||||||
|
for _, match := range series.Matches {
|
||||||
|
@seriesAnalysisMatchRow(series, match)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesAnalysisMatchRow(series *db.PlayoffSeries, match *db.PlayoffMatch) {
|
||||||
|
{{
|
||||||
|
matchLabel := fmt.Sprintf("Game %d", match.MatchNumber)
|
||||||
|
isCompleted := match.Status == "completed"
|
||||||
|
}}
|
||||||
|
<div class="flex items-center justify-between px-6 py-3 hover:bg-surface0 transition-colors">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-sm font-medium text-subtext0 w-16">{ matchLabel }</span>
|
||||||
|
if isCompleted {
|
||||||
|
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs">
|
||||||
|
Complete
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-1.5 py-0.5 bg-surface1 text-subtext0 rounded text-xs">
|
||||||
|
{ match.Status }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if match.FixtureID != nil {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/overview", *match.FixtureID)) }
|
||||||
|
class="px-3 py-1 bg-surface1 hover:bg-surface2 text-text rounded text-xs
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesAnalysisLeagueContext shows how the teams sit in the league standings.
|
||||||
|
templ seriesAnalysisLeagueContext(series *db.PlayoffSeries, preview *db.MatchPreviewData) {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">League Context</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Team Name Headers -->
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="flex-1 text-right pr-4">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
if series.Team1 != nil && series.Team1.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<span class="text-sm font-bold text-text">{ seriesTeamShortName(series.Team1) }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-28 sm:w-36 text-center shrink-0"></div>
|
||||||
|
<div class="flex-1 text-left pl-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-bold text-text">{ seriesTeamShortName(series.Team2) }</span>
|
||||||
|
if series.Team2 != nil && series.Team2.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-0">
|
||||||
|
{{
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
<!-- Recent Form -->
|
||||||
|
if len(preview.HomeRecentGames) > 0 || len(preview.AwayRecentGames) > 0 {
|
||||||
|
<div class="flex items-center py-3 border-b border-surface1 last:border-b-0">
|
||||||
|
<div class="flex-1 flex justify-end pr-4">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
for _, g := range preview.HomeRecentGames {
|
||||||
|
@gameOutcomeIcon(g)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-28 sm:w-36 text-center shrink-0">
|
||||||
|
<span class="text-xs font-semibold text-subtext0 uppercase tracking-wider">Form</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex pl-4">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
for _, g := range preview.AwayRecentGames {
|
||||||
|
@gameOutcomeIcon(g)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
319
internal/view/seasonsview/series_match_preview.templ
Normal file
319
internal/view/seasonsview/series_match_preview.templ
Normal file
@@ -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,
|
||||||
|
) {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Team Comparison Header -->
|
||||||
|
if preview != nil {
|
||||||
|
@seriesPreviewHeader(series, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Form Guide (Last 5 Games) -->
|
||||||
|
if preview != nil {
|
||||||
|
@seriesPreviewFormGuide(series, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Team Rosters -->
|
||||||
|
@seriesPreviewRosters(series, rosters)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesPreviewHeader renders the broadcast-style team comparison with standings.
|
||||||
|
templ seriesPreviewHeader(series *db.PlayoffSeries, preview *db.MatchPreviewData) {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Team Comparison</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Team Names and VS -->
|
||||||
|
<div class="flex items-center justify-center gap-4 sm:gap-8 mb-8">
|
||||||
|
<!-- Team 1 -->
|
||||||
|
<div class="flex flex-col items-center text-center flex-1">
|
||||||
|
if series.Team1 != nil && series.Team1.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<h3 class="text-xl sm:text-2xl font-bold text-text">
|
||||||
|
if series.Team1 != nil {
|
||||||
|
@links.TeamNameLinkInSeason(series.Team1, series.Bracket.Season, series.Bracket.League)
|
||||||
|
} else {
|
||||||
|
TBD
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
if series.Team1 != nil {
|
||||||
|
<span class="text-subtext0 text-sm font-mono mt-1">{ series.Team1.ShortName }</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- VS Divider -->
|
||||||
|
<div class="flex flex-col items-center shrink-0">
|
||||||
|
<span class="text-3xl sm:text-4xl font-bold text-subtext0">VS</span>
|
||||||
|
</div>
|
||||||
|
<!-- Team 2 -->
|
||||||
|
<div class="flex flex-col items-center text-center flex-1">
|
||||||
|
if series.Team2 != nil && series.Team2.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<h3 class="text-xl sm:text-2xl font-bold text-text">
|
||||||
|
if series.Team2 != nil {
|
||||||
|
@links.TeamNameLinkInSeason(series.Team2, series.Bracket.Season, series.Bracket.League)
|
||||||
|
} else {
|
||||||
|
TBD
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
if series.Team2 != nil {
|
||||||
|
<span class="text-subtext0 text-sm font-mono mt-1">{ series.Team2.ShortName }</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Stats Comparison Grid -->
|
||||||
|
{{
|
||||||
|
homePos := ordinal(preview.HomePosition)
|
||||||
|
awayPos := ordinal(preview.AwayPosition)
|
||||||
|
if preview.HomePosition == 0 {
|
||||||
|
homePos = "N/A"
|
||||||
|
}
|
||||||
|
if preview.AwayPosition == 0 {
|
||||||
|
awayPos = "N/A"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class="space-y-0">
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesPreviewFormGuide renders recent form for each team.
|
||||||
|
templ seriesPreviewFormGuide(series *db.PlayoffSeries, preview *db.MatchPreviewData) {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Recent Form</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<!-- Team 1 Form -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
if series.Team1 != nil && series.Team1.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<h3 class="text-md font-bold text-text">{ seriesTeamName(series.Team1) }</h3>
|
||||||
|
</div>
|
||||||
|
if len(preview.HomeRecentGames) == 0 {
|
||||||
|
<p class="text-subtext1 text-sm">No recent matches played</p>
|
||||||
|
} else {
|
||||||
|
<div class="flex items-center gap-1.5 mb-4">
|
||||||
|
for _, g := range preview.HomeRecentGames {
|
||||||
|
@gameOutcomeIcon(g)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
for i := len(preview.HomeRecentGames) - 1; i >= 0; i-- {
|
||||||
|
@recentGameRow(preview.HomeRecentGames[i])
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Team 2 Form -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
if series.Team2 != nil && series.Team2.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<h3 class="text-md font-bold text-text">{ seriesTeamName(series.Team2) }</h3>
|
||||||
|
</div>
|
||||||
|
if len(preview.AwayRecentGames) == 0 {
|
||||||
|
<p class="text-subtext1 text-sm">No recent matches played</p>
|
||||||
|
} else {
|
||||||
|
<div class="flex items-center gap-1.5 mb-4">
|
||||||
|
for _, g := range preview.AwayRecentGames {
|
||||||
|
@gameOutcomeIcon(g)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
for i := len(preview.AwayRecentGames) - 1; i >= 0; i-- {
|
||||||
|
@recentGameRow(preview.AwayRecentGames[i])
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesPreviewRosters renders team rosters side-by-side.
|
||||||
|
templ seriesPreviewRosters(series *db.PlayoffSeries, rosters map[string][]*db.PlayerWithPlayStatus) {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Team Rosters</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
if team.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3.5 h-3.5 rounded-full shrink-0 border border-surface1"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<h3 class="text-md font-bold">
|
||||||
|
@links.TeamNameLinkInSeason(team, season, league)
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-subtext0">
|
||||||
|
{ fmt.Sprint(len(players)) } players
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
if len(players) == 0 {
|
||||||
|
<p class="text-subtext1 text-sm text-center py-4">No players on roster.</p>
|
||||||
|
} else {
|
||||||
|
<div class="space-y-1">
|
||||||
|
for _, p := range managers {
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface0 border border-surface1">
|
||||||
|
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium shrink-0">
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
@links.PlayerLink(p.Player)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
for _, p := range roster {
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface0 transition">
|
||||||
|
<span class="text-sm">
|
||||||
|
@links.PlayerLink(p.Player)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
504
internal/view/seasonsview/series_schedule.templ
Normal file
504
internal/view/seasonsview/series_schedule.templ
Normal file
@@ -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,
|
||||||
|
) {
|
||||||
|
<div class="space-y-6">
|
||||||
|
@seriesScheduleStatus(series, currentSchedule, canSchedule, canManage, userTeamID)
|
||||||
|
@seriesScheduleActions(series, currentSchedule, canSchedule, canManage, userTeamID)
|
||||||
|
@seriesScheduleHistory(series, history)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesScheduleStatus(
|
||||||
|
series *db.PlayoffSeries,
|
||||||
|
current *db.PlayoffSeriesSchedule,
|
||||||
|
canSchedule bool,
|
||||||
|
canManage bool,
|
||||||
|
userTeamID int,
|
||||||
|
) {
|
||||||
|
{{
|
||||||
|
bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Schedule Status</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
if !bothTeamsAssigned {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">⏳</div>
|
||||||
|
<p class="text-lg text-text font-medium">Waiting for Teams</p>
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
Both teams must be determined before scheduling can begin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if current == nil {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">📅</div>
|
||||||
|
<p class="text-lg text-text font-medium">No time scheduled</p>
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
if canSchedule {
|
||||||
|
Use the form to propose a time for this series.
|
||||||
|
} else {
|
||||||
|
A team manager needs to propose a time for this series.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusPending && current.ScheduledTime != nil {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">⏳</div>
|
||||||
|
<p class="text-lg text-text font-medium">
|
||||||
|
Proposed:
|
||||||
|
@localtime(current.ScheduledTime, "datetime")
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
Proposed by
|
||||||
|
<span class="text-text font-medium">{ current.ProposedBy.Name }</span>
|
||||||
|
— awaiting response from the other team
|
||||||
|
</p>
|
||||||
|
if canSchedule && userTeamID != current.ProposedByTeamID {
|
||||||
|
<div class="flex justify-center gap-3 mt-4">
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/series/%d/schedule/%d/accept", series.ID, current.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/series/%d/schedule/%d/reject", series.ID, current.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if canSchedule && userTeamID == current.ProposedByTeamID {
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click={ "window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Withdraw Proposal', message: 'Are you sure you want to withdraw your proposed time?', action: () => htmx.ajax('POST', '/series/" + fmt.Sprint(series.ID) + "/schedule/" + fmt.Sprint(current.ID) + "/withdraw', { swap: 'none' }) } }))" }
|
||||||
|
class="px-4 py-2 bg-surface1 hover:bg-surface2 text-text rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Withdraw Proposal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusAccepted {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">✅</div>
|
||||||
|
<p class="text-lg text-green font-medium">
|
||||||
|
Confirmed:
|
||||||
|
@localtime(current.ScheduledTime, "datetime")
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
Both teams have agreed on this time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusRejected {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">❌</div>
|
||||||
|
<p class="text-lg text-red font-medium">Proposal Rejected</p>
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
The proposed time was rejected. A new time needs to be proposed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusCancelled {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">🚫</div>
|
||||||
|
<p class="text-lg text-red font-medium">Schedule Cancelled</p>
|
||||||
|
if current.RescheduleReason != nil {
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
{ *current.RescheduleReason }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusRescheduled {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">🔄</div>
|
||||||
|
<p class="text-lg text-yellow font-medium">Rescheduled</p>
|
||||||
|
if current.RescheduleReason != nil {
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
Reason: { *current.RescheduleReason }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
A new time needs to be proposed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusPostponed {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">⏸️</div>
|
||||||
|
<p class="text-lg text-peach font-medium">Postponed</p>
|
||||||
|
if current.RescheduleReason != nil {
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
Reason: { *current.RescheduleReason }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
A new time needs to be proposed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} else if current.Status == db.ScheduleStatusWithdrawn {
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-4xl mb-3">↩️</div>
|
||||||
|
<p class="text-lg text-subtext0 font-medium">Proposal Withdrawn</p>
|
||||||
|
<p class="text-sm text-subtext1 mt-1">
|
||||||
|
The proposed time was withdrawn. A new time needs to be proposed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ 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 {
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<!-- Propose Time -->
|
||||||
|
if showPropose {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
||||||
|
<h3 class="text-md font-bold text-text">Propose Time</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/series/%d/schedule", series.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-subtext0 mb-1">
|
||||||
|
Date & Time
|
||||||
|
<span class="relative group inline-block ml-1">
|
||||||
|
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium cursor-help">AEST/AEDT</span>
|
||||||
|
<span
|
||||||
|
class="absolute left-0 top-full mt-1 w-56 bg-crust border border-surface1 rounded-lg
|
||||||
|
p-2 text-xs text-subtext0 shadow-lg opacity-0 invisible
|
||||||
|
group-hover:opacity-100 group-hover:visible transition-all z-50"
|
||||||
|
>
|
||||||
|
Enter the time in Australian Eastern time (AEST/AEDT). It will be displayed in each user's local timezone.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="scheduled_time"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
focus:border-blue focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Propose Time
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Reschedule -->
|
||||||
|
if showReschedule {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
||||||
|
<h3 class="text-md font-bold text-text">Reschedule</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/series/%d/schedule/reschedule", series.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-subtext0 mb-1">
|
||||||
|
New Date & Time
|
||||||
|
<span class="relative group inline-block ml-1">
|
||||||
|
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium cursor-help">AEST/AEDT</span>
|
||||||
|
<span
|
||||||
|
class="absolute left-0 top-full mt-1 w-56 bg-crust border border-surface1 rounded-lg
|
||||||
|
p-2 text-xs text-subtext0 shadow-lg opacity-0 invisible
|
||||||
|
group-hover:opacity-100 group-hover:visible transition-all z-50"
|
||||||
|
>
|
||||||
|
Enter the time in Australian Eastern time (AEST/AEDT). It will be displayed in each user's local timezone.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="scheduled_time"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
focus:border-blue focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
|
||||||
|
@seriesRescheduleReasonSelect(series)
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full px-4 py-2 bg-yellow hover:bg-yellow/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Reschedule
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Postpone -->
|
||||||
|
if showPostpone {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
||||||
|
<h3 class="text-md font-bold text-text">Postpone</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
|
||||||
|
@seriesRescheduleReasonSelect(series)
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click={ fmt.Sprintf("if (!$el.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Postpone Series', message: 'Are you sure you want to postpone this series? The current schedule will be cleared.', action: () => htmx.ajax('POST', '/series/%d/schedule/postpone', { swap: 'none', values: { reschedule_reason: $el.parentElement.querySelector('select').value } }) } }))", series.ID) }
|
||||||
|
class="w-full px-4 py-2 bg-peach hover:bg-peach/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Postpone Series
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Cancel (moderator only) -->
|
||||||
|
if showCancel {
|
||||||
|
<div class="bg-mantle border border-red/30 rounded-lg">
|
||||||
|
<div class="bg-red/10 border-b border-red/30 px-4 py-3 rounded-t-lg">
|
||||||
|
<h3 class="text-md font-bold text-red">Cancel Schedule</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<p class="text-xs text-red/80 mb-3 font-medium">
|
||||||
|
This action will cancel the current series schedule.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="reschedule_reason"
|
||||||
|
placeholder="Enter reason..."
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
focus:border-blue focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click={ fmt.Sprintf("const reason = $el.parentElement.querySelector('input[name=reschedule_reason]').value; if (!reason) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Cancel Schedule', message: 'Are you sure you want to cancel this schedule?', action: () => htmx.ajax('POST', '/series/%d/schedule/cancel', { swap: 'none', values: { reschedule_reason: reason } }) } }))", series.ID) }
|
||||||
|
class="w-full px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel Schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
if !canSchedule && !canManage {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||||||
|
<p class="text-subtext1 text-sm">
|
||||||
|
Only team managers can manage series scheduling.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesRescheduleReasonSelect(series *db.PlayoffSeries) {
|
||||||
|
{{
|
||||||
|
team1Name := seriesTeamName(series.Team1)
|
||||||
|
team2Name := seriesTeamName(series.Team2)
|
||||||
|
}}
|
||||||
|
<select
|
||||||
|
name="reschedule_reason"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
focus:border-blue focus:outline-none hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="" disabled selected>Select a reason</option>
|
||||||
|
<option value="Mutually Agreed">Mutually Agreed</option>
|
||||||
|
<option value={ fmt.Sprintf("%s Unavailable", team1Name) }>
|
||||||
|
{ team1Name } Unavailable
|
||||||
|
</option>
|
||||||
|
<option value={ fmt.Sprintf("%s Unavailable", team2Name) }>
|
||||||
|
{ team2Name } Unavailable
|
||||||
|
</option>
|
||||||
|
<option value={ fmt.Sprintf("%s No-show", team1Name) }>
|
||||||
|
{ team1Name } No-show
|
||||||
|
</option>
|
||||||
|
<option value={ fmt.Sprintf("%s No-show", team2Name) }>
|
||||||
|
{ team2Name } No-show
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seriesScheduleHistory(series *db.PlayoffSeries, history []*db.PlayoffSeriesSchedule) {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Schedule History</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
if len(history) == 0 {
|
||||||
|
<p class="text-subtext1 text-sm text-center py-4">No scheduling activity yet.</p>
|
||||||
|
} else {
|
||||||
|
<div class="space-y-3">
|
||||||
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
|
@seriesScheduleHistoryItem(history[i], i == len(history)-1)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class={ "border rounded-lg p-3", templ.KV("border-surface1", !isCurrent), templ.KV("border-blue/30 bg-blue/5", isCurrent) }>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
if isCurrent {
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-blue/20 text-blue rounded font-medium">
|
||||||
|
CURRENT
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class={ "text-xs px-2 py-0.5 rounded font-medium", statusBg, statusColor }>
|
||||||
|
{ statusLabel }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-subtext1">
|
||||||
|
@localtimeUnix(schedule.CreatedAt, "histdate")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-subtext0">Proposed by:</span>
|
||||||
|
<span class="text-text font-medium">{ schedule.ProposedBy.Name }</span>
|
||||||
|
</div>
|
||||||
|
if schedule.ScheduledTime != nil {
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-subtext0">Time:</span>
|
||||||
|
<span class="text-text">
|
||||||
|
@localtime(schedule.ScheduledTime, "datetime")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-subtext0">Time:</span>
|
||||||
|
<span class="text-subtext1 italic">No time set</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if schedule.RescheduleReason != nil {
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-subtext0">Reason:</span>
|
||||||
|
<span class="text-subtext1">{ *schedule.RescheduleReason }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user