Compare commits

..

33 Commits

Author SHA1 Message Date
ba0844048a playoff visual fixes 2026-03-09 13:01:28 +11:00
1cab39a4f7 finals generation added 2026-03-08 18:12:03 +11:00
26ee81964d Merge branch 'staging' 2026-03-07 19:33:00 +11:00
e0fd3b0a45 Merge branch 'homepage' into development 2026-03-07 14:34:04 +11:00
34cba6a81f added kofi link 2026-03-07 14:33:13 +11:00
14e10d095e removed placeholders from footer 2026-03-07 14:23:05 +11:00
dd1ed61adb home page added 2026-03-07 14:18:06 +11:00
9ad29586f2 Merge branch 'fixes' into development 2026-03-07 13:24:51 +11:00
78db8d0324 added staging banner 2026-03-07 13:24:36 +11:00
04389970ac stats page layout tweaks 2026-03-07 13:13:09 +11:00
1194d46613 navbar tweaks 2026-03-07 13:06:58 +11:00
3b1eeaf12d scroll fixes 2026-03-07 13:01:08 +11:00
4064c9c557 fixed build recipe not generating templ files 2026-03-07 12:36:15 +11:00
9f6a2303a0 fixed fixture page to use htmx tab pattern 2026-03-07 12:34:49 +11:00
8b414ff7f0 changed registration to have blank username on first load 2026-03-07 12:22:11 +11:00
7b934295c6 removed theme switcher, always uses dark theme 2026-03-07 11:53:49 +11:00
c73758c91c Merge branch 'stats' into development 2026-03-07 11:47:59 +11:00
f5c9e70edf added match preview and analysis 2026-03-06 22:08:41 +11:00
b957df8d32 fixed stat sorting 2026-03-06 21:40:18 +11:00
b96aeef32e added league stats 2026-03-06 21:37:02 +11:00
ce659f7d56 added stat leaderboards 2026-03-06 21:25:05 +11:00
a472314474 removed "registered by" field for free agents 2026-03-06 21:05:55 +11:00
76987adceb added better links to teams and players 2026-03-06 21:03:51 +11:00
060301f2c2 added player stats to profile 2026-03-06 20:48:21 +11:00
788346d269 player profile added 2026-03-06 19:51:27 +11:00
b57fbcd302 updated team stats 2026-03-06 19:06:29 +11:00
1634b27991 added team overview 2026-03-06 18:53:58 +11:00
cade057e42 added ot stats to team view 2026-03-06 18:29:23 +11:00
e526f42ac3 added periods played 2026-03-06 18:23:37 +11:00
088478e6c1 Merge branch 'forfeits' into development 2026-03-06 18:01:39 +11:00
08344877c7 forfeits added 2026-03-05 22:22:32 +11:00
36b42d6267 Merge branch 'development' into staging 2026-03-05 18:57:37 +11:00
2b5c43cf61 playernames now added on creation 2026-03-05 18:56:48 +11:00
26 changed files with 60 additions and 5504 deletions

View File

@@ -513,7 +513,6 @@ func GetAllLeaguePlayerStats(
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_id = ?
AND f.round > 0
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
@@ -584,7 +583,6 @@ func GetTopGoalScorers(
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_id = ?
AND f.round > 0
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
@@ -657,7 +655,6 @@ func GetTopAssisters(
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_id = ?
AND f.round > 0
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
@@ -730,7 +727,6 @@ func GetTopSavers(
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_id = ?
AND f.round > 0
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)

View File

@@ -1,58 +0,0 @@
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
},
)
}

View File

@@ -1,71 +0,0 @@
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 {
// Add is_forfeit column
_, err := conn.NewAddColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("is_forfeit BOOLEAN NOT NULL DEFAULT false").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Add forfeit_team_id column
_, err = conn.NewAddColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("forfeit_team_id INTEGER REFERENCES teams(id)").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Add forfeit_reason column
_, err = conn.NewAddColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("forfeit_reason VARCHAR").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
_, err := conn.NewDropColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("forfeit_reason").
Exec(ctx)
if err != nil {
return err
}
_, err = conn.NewDropColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("forfeit_team_id").
Exec(ctx)
if err != nil {
return err
}
_, err = conn.NewDropColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("is_forfeit").
Exec(ctx)
return err
},
)
}

View File

@@ -81,17 +81,11 @@ type PlayoffSeries struct {
LoserNextSlot *string `bun:"loser_next_slot"` // "team1" or "team2" in loser's next series
CreatedAt int64 `bun:",notnull"`
// Forfeit-related fields
IsForfeit bool `bun:"is_forfeit,default:false"`
ForfeitTeamID *int `bun:"forfeit_team_id"` // Which team forfeited
ForfeitReason *string `bun:"forfeit_reason"` // Admin-provided reason
Bracket *PlayoffBracket `bun:"rel:belongs-to,join:bracket_id=id"`
Team1 *Team `bun:"rel:belongs-to,join:team1_id=id"`
Team2 *Team `bun:"rel:belongs-to,join:team2_id=id"`
Winner *Team `bun:"rel:belongs-to,join:winner_team_id=id"`
Loser *Team `bun:"rel:belongs-to,join:loser_team_id=id"`
ForfeitTeam *Team `bun:"rel:belongs-to,join:forfeit_team_id=id"`
Matches []*PlayoffMatch `bun:"rel:has-many,join:id=series_id"`
}
@@ -330,225 +324,3 @@ func AutoForfeitUnplayedFixtures(
return len(unplayed), nil
}
// GetPlayoffSeriesByID retrieves a single playoff series with all relations needed
// for the series detail page.
func GetPlayoffSeriesByID(
ctx context.Context,
tx bun.Tx,
seriesID int,
) (*PlayoffSeries, error) {
series := new(PlayoffSeries)
err := tx.NewSelect().
Model(series).
Where("ps.id = ?", seriesID).
Relation("Bracket").
Relation("Bracket.Season").
Relation("Bracket.League").
Relation("Bracket.Series").
Relation("Team1").
Relation("Team2").
Relation("Winner").
Relation("Loser").
Relation("ForfeitTeam").
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
}

View File

@@ -1,498 +0,0 @@
package db
import (
"context"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// playoffFixtureRound generates a unique negative round number for a playoff game fixture.
// Format: -(seriesID * 100 + matchNumber) to avoid collision with regular season rounds.
func playoffFixtureRound(seriesID, matchNumber int) int {
return -(seriesID*100 + matchNumber)
}
// CreatePlayoffGameFixture creates a Fixture record for a playoff game.
// The fixture is linked to the series via a PlayoffMatch record.
// team1 is "home", team2 is "away" in fixture terms.
func CreatePlayoffGameFixture(
ctx context.Context,
tx bun.Tx,
series *PlayoffSeries,
matchNumber int,
audit *AuditMeta,
) (*Fixture, *PlayoffMatch, error) {
if series == nil || series.Bracket == nil {
return nil, nil, errors.New("series and bracket cannot be nil")
}
if series.Team1ID == nil || series.Team2ID == nil {
return nil, nil, BadRequest("both teams must be assigned to create a game fixture")
}
round := playoffFixtureRound(series.ID, matchNumber)
fixture := &Fixture{
SeasonID: series.Bracket.SeasonID,
LeagueID: series.Bracket.LeagueID,
HomeTeamID: *series.Team1ID,
AwayTeamID: *series.Team2ID,
Round: round,
CreatedAt: time.Now().Unix(),
}
err := Insert(tx, fixture).WithAudit(audit, &AuditInfo{
Action: "playoff_fixture.create",
ResourceType: "fixture",
ResourceID: nil,
Details: map[string]any{
"series_id": series.ID,
"match_number": matchNumber,
"home_team_id": *series.Team1ID,
"away_team_id": *series.Team2ID,
},
}).Exec(ctx)
if err != nil {
return nil, nil, errors.Wrap(err, "Insert fixture")
}
// Create or update PlayoffMatch record
match := new(PlayoffMatch)
err = tx.NewSelect().
Model(match).
Where("pm.series_id = ?", series.ID).
Where("pm.match_number = ?", matchNumber).
Scan(ctx)
if err != nil && err.Error() != "sql: no rows in result set" {
return nil, nil, errors.Wrap(err, "tx.NewSelect playoff_match")
}
if match.ID > 0 {
// Update existing match with fixture ID
match.FixtureID = &fixture.ID
match.HomeTeamID = series.Team1ID
match.AwayTeamID = series.Team2ID
match.Status = "pending"
err = UpdateByID(tx, match.ID, match).
Column("fixture_id", "home_team_id", "away_team_id", "status").
Exec(ctx)
if err != nil {
return nil, nil, errors.Wrap(err, "UpdateByID playoff_match")
}
} else {
// Create new match
match = &PlayoffMatch{
SeriesID: series.ID,
MatchNumber: matchNumber,
HomeTeamID: series.Team1ID,
AwayTeamID: series.Team2ID,
FixtureID: &fixture.ID,
Status: "pending",
}
err = Insert(tx, match).Exec(ctx)
if err != nil {
return nil, nil, errors.Wrap(err, "Insert playoff_match")
}
}
// Load fixture relations
fixture.Season = series.Bracket.Season
fixture.League = series.Bracket.League
fixture.HomeTeam = series.Team1
fixture.AwayTeam = series.Team2
return fixture, match, nil
}
// FinalizeSeriesResults finalizes all pending game results for a series,
// updates series wins/status, and advances teams as needed.
// Returns the number of games finalized.
func FinalizeSeriesResults(
ctx context.Context,
tx bun.Tx,
seriesID int,
audit *AuditMeta,
) (int, error) {
series, err := GetPlayoffSeriesByID(ctx, tx, seriesID)
if err != nil {
return 0, errors.Wrap(err, "GetPlayoffSeriesByID")
}
if series == nil {
return 0, BadRequest("series not found")
}
if series.Status == SeriesStatusCompleted {
return 0, BadRequest("series is already completed")
}
// Collect all matches with fixtures that have pending results
gamesFinalized := 0
team1Wins := 0
team2Wins := 0
for _, match := range series.Matches {
if match.FixtureID == nil {
continue
}
result, err := GetFixtureResult(ctx, tx, *match.FixtureID)
if err != nil {
return 0, errors.Wrap(err, "GetFixtureResult")
}
if result == nil {
continue
}
// Finalize the fixture result if pending
if !result.Finalized {
err = FinalizeFixtureResult(ctx, tx, *match.FixtureID, audit)
if err != nil {
return 0, errors.Wrap(err, "FinalizeFixtureResult")
}
gamesFinalized++
}
// Update match status
now := time.Now().Unix()
match.Status = "completed"
err = UpdateByID(tx, match.ID, match).
Column("status").
Exec(ctx)
if err != nil {
return 0, errors.Wrap(err, "UpdateByID playoff_match")
}
_ = now
// Count wins: team1 = home, team2 = away in fixture terms
if result.Winner == "home" {
team1Wins++
} else {
team2Wins++
}
}
if gamesFinalized == 0 {
return 0, BadRequest("no pending results to finalize")
}
// Update series wins
series.Team1Wins = team1Wins
series.Team2Wins = team2Wins
// Determine if series is decided
if team1Wins >= series.MatchesToWin || team2Wins >= series.MatchesToWin {
series.Status = SeriesStatusCompleted
if team1Wins >= series.MatchesToWin {
series.WinnerTeamID = series.Team1ID
series.LoserTeamID = series.Team2ID
} else {
series.WinnerTeamID = series.Team2ID
series.LoserTeamID = series.Team1ID
}
err = UpdateByID(tx, series.ID, series).
Column("team1_wins", "team2_wins", "status", "winner_team_id", "loser_team_id").
WithAudit(audit, &AuditInfo{
Action: "playoff_series.complete",
ResourceType: "playoff_series",
ResourceID: series.ID,
Details: map[string]any{
"team1_wins": team1Wins,
"team2_wins": team2Wins,
"winner_team_id": series.WinnerTeamID,
"loser_team_id": series.LoserTeamID,
},
}).Exec(ctx)
if err != nil {
return 0, errors.Wrap(err, "UpdateByID series complete")
}
// Advance winner to next series
if series.WinnerNextID != nil && series.WinnerNextSlot != nil {
err = advanceTeamToSeries(ctx, tx, *series.WinnerNextID, *series.WinnerNextSlot, *series.WinnerTeamID)
if err != nil {
return 0, errors.Wrap(err, "advanceTeamToSeries winner")
}
}
// Advance loser to next series (e.g. third place, lower bracket)
if series.LoserNextID != nil && series.LoserNextSlot != nil {
err = advanceTeamToSeries(ctx, tx, *series.LoserNextID, *series.LoserNextSlot, *series.LoserTeamID)
if err != nil {
return 0, errors.Wrap(err, "advanceTeamToSeries loser")
}
}
} else {
// Series still in progress
series.Status = SeriesStatusInProgress
err = UpdateByID(tx, series.ID, series).
Column("team1_wins", "team2_wins", "status").
Exec(ctx)
if err != nil {
return 0, errors.Wrap(err, "UpdateByID series in_progress")
}
}
return gamesFinalized, nil
}
// advanceTeamToSeries places a team into the specified slot of the target series.
func advanceTeamToSeries(ctx context.Context, tx bun.Tx, targetSeriesID int, slot string, teamID int) error {
switch slot {
case "team1":
_, err := tx.NewUpdate().
Model((*PlayoffSeries)(nil)).
Set("team1_id = ?", teamID).
Where("id = ?", targetSeriesID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "update team1_id")
}
case "team2":
_, err := tx.NewUpdate().
Model((*PlayoffSeries)(nil)).
Set("team2_id = ?", teamID).
Where("id = ?", targetSeriesID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "update team2_id")
}
default:
return BadRequest("invalid slot: " + slot)
}
return nil
}
// DeleteSeriesResults deletes all pending (non-finalized) fixture results
// and their associated fixtures for a series.
func DeleteSeriesResults(
ctx context.Context,
tx bun.Tx,
seriesID int,
audit *AuditMeta,
) error {
series, err := GetPlayoffSeriesByID(ctx, tx, seriesID)
if err != nil {
return errors.Wrap(err, "GetPlayoffSeriesByID")
}
if series == nil {
return BadRequest("series not found")
}
for _, match := range series.Matches {
if match.FixtureID == nil {
continue
}
result, err := GetFixtureResult(ctx, tx, *match.FixtureID)
if err != nil {
return errors.Wrap(err, "GetFixtureResult")
}
if result == nil {
continue
}
if result.Finalized {
return BadRequest("cannot discard finalized results")
}
// Delete the result (CASCADE deletes player stats)
err = DeleteFixtureResult(ctx, tx, *match.FixtureID, audit)
if err != nil {
return errors.Wrap(err, "DeleteFixtureResult")
}
// Delete the fixture
err = DeleteByID[Fixture](tx, *match.FixtureID).
WithAudit(audit, &AuditInfo{
Action: "playoff_fixture.delete",
ResourceType: "fixture",
ResourceID: *match.FixtureID,
}).Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteByID fixture")
}
// Clear fixture ID from match
match.FixtureID = nil
match.Status = "pending"
err = UpdateByID(tx, match.ID, match).
Column("fixture_id", "status").
Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID playoff_match")
}
}
return nil
}
// ForfeitSeries forfeits a playoff series. The forfeiting team loses and the opponent
// is declared the winner and advances through the bracket. Any existing match results
// and fixtures are discarded. The series score (Team1Wins/Team2Wins) is left as-is.
func ForfeitSeries(
ctx context.Context,
tx bun.Tx,
seriesID int,
forfeitTeamID int,
reason string,
audit *AuditMeta,
) error {
series, err := GetPlayoffSeriesByID(ctx, tx, seriesID)
if err != nil {
return errors.Wrap(err, "GetPlayoffSeriesByID")
}
if series == nil {
return BadRequest("series not found")
}
if series.Status == SeriesStatusCompleted {
return BadRequest("series is already completed")
}
if series.Status == SeriesStatusBye {
return BadRequest("cannot forfeit a bye series")
}
if series.Team1ID == nil || series.Team2ID == nil {
return BadRequest("both teams must be assigned to forfeit a series")
}
// Validate forfeit team is one of the teams in the series
if forfeitTeamID != *series.Team1ID && forfeitTeamID != *series.Team2ID {
return BadRequest("forfeit team must be one of the teams in the series")
}
// Determine winner and loser
var winnerTeamID int
if forfeitTeamID == *series.Team1ID {
winnerTeamID = *series.Team2ID
} else {
winnerTeamID = *series.Team1ID
}
// Discard all existing match results and fixtures
for _, match := range series.Matches {
if match.FixtureID == nil {
continue
}
result, err := GetFixtureResult(ctx, tx, *match.FixtureID)
if err != nil {
return errors.Wrap(err, "GetFixtureResult")
}
if result != nil {
// Delete result (CASCADE deletes player stats)
err = DeleteByID[FixtureResult](tx, result.ID).
WithAudit(audit, &AuditInfo{
Action: "fixture_results.discard_for_forfeit",
ResourceType: "fixture_result",
ResourceID: result.ID,
Details: map[string]any{
"fixture_id": *match.FixtureID,
"series_id": seriesID,
},
}).Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteByID fixture_result")
}
}
// Delete the fixture
err = DeleteByID[Fixture](tx, *match.FixtureID).
WithAudit(audit, &AuditInfo{
Action: "playoff_fixture.delete_for_forfeit",
ResourceType: "fixture",
ResourceID: *match.FixtureID,
Details: map[string]any{
"series_id": seriesID,
},
}).Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteByID fixture")
}
// Clear fixture ID from match
match.FixtureID = nil
match.Status = "pending"
err = UpdateByID(tx, match.ID, match).
Column("fixture_id", "status").
Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID playoff_match")
}
}
// Update series with forfeit info
var reasonPtr *string
if reason != "" {
reasonPtr = &reason
}
series.Status = SeriesStatusCompleted
series.IsForfeit = true
series.ForfeitTeamID = &forfeitTeamID
series.ForfeitReason = reasonPtr
series.WinnerTeamID = &winnerTeamID
series.LoserTeamID = &forfeitTeamID
err = UpdateByID(tx, series.ID, series).
Column("status", "is_forfeit", "forfeit_team_id", "forfeit_reason", "winner_team_id", "loser_team_id").
WithAudit(audit, &AuditInfo{
Action: "playoff_series.forfeit",
ResourceType: "playoff_series",
ResourceID: series.ID,
Details: map[string]any{
"forfeit_team_id": forfeitTeamID,
"winner_team_id": winnerTeamID,
"reason": reason,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID series forfeit")
}
// Advance winner to next series
if series.WinnerNextID != nil && series.WinnerNextSlot != nil {
err = advanceTeamToSeries(ctx, tx, *series.WinnerNextID, *series.WinnerNextSlot, winnerTeamID)
if err != nil {
return errors.Wrap(err, "advanceTeamToSeries winner")
}
}
// Advance loser to next series (e.g. lower bracket)
if series.LoserNextID != nil && series.LoserNextSlot != nil {
err = advanceTeamToSeries(ctx, tx, *series.LoserNextID, *series.LoserNextSlot, forfeitTeamID)
if err != nil {
return errors.Wrap(err, "advanceTeamToSeries loser")
}
}
return nil
}
// HasPendingSeriesResults checks if a series has any pending (non-finalized) results.
func HasPendingSeriesResults(ctx context.Context, tx bun.Tx, seriesID int) (bool, error) {
series, err := GetPlayoffSeriesByID(ctx, tx, seriesID)
if err != nil {
return false, errors.Wrap(err, "GetPlayoffSeriesByID")
}
if series == nil {
return false, nil
}
for _, match := range series.Matches {
if match.FixtureID == nil {
continue
}
result, err := GetPendingFixtureResult(ctx, tx, *match.FixtureID)
if err != nil {
return false, errors.Wrap(err, "GetPendingFixtureResult")
}
if result != nil {
return true, nil
}
}
return false, nil
}

View File

@@ -1,370 +0,0 @@
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
}

View File

@@ -1,256 +0,0 @@
package db
import (
"context"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// GetPlayoffPlayerStats returns aggregated player stats from playoff fixtures only
// (fixtures with round < 0) for a season-league.
// Reuses the same LeaguePlayerStats struct as regular season stats.
func GetPlayoffPlayerStats(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*LeaguePlayerStats, error) {
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
if leagueID == 0 {
return nil, errors.New("leagueID not provided")
}
var stats []*LeaguePlayerStats
err := tx.NewRaw(`
SELECT
agg.player_id,
agg.player_name,
COALESCE(tr.team_id, 0) AS team_id,
COALESCE(t.name, '') AS team_name,
COALESCE(t.color, '') AS team_color,
agg.games_played,
agg.total_periods_played,
agg.total_goals,
agg.total_assists,
agg.total_primary_assists,
agg.total_secondary_assists,
agg.total_saves,
agg.total_shots,
agg.total_blocks,
agg.total_passes,
agg.total_score
FROM (
SELECT
frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name,
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.goals), 0) AS total_goals,
COALESCE(SUM(frps.assists), 0) AS total_assists,
COALESCE(SUM(frps.primary_assists), 0) AS total_primary_assists,
COALESCE(SUM(frps.secondary_assists), 0) AS total_secondary_assists,
COALESCE(SUM(frps.saves), 0) AS total_saves,
COALESCE(SUM(frps.shots), 0) AS total_shots,
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
COALESCE(SUM(frps.passes), 0) AS total_passes,
COALESCE(SUM(frps.score), 0) AS total_score
FROM fixture_result_player_stats frps
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
JOIN fixtures f ON f.id = fr.fixture_id
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_id = ?
AND f.round < 0
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
) agg
LEFT JOIN team_rosters tr
ON tr.player_id = agg.player_id
AND tr.season_id = ?
AND tr.league_id = ?
LEFT JOIN teams t ON t.id = tr.team_id
ORDER BY agg.total_score DESC
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// GetPlayoffTopGoalScorers returns the top 10 goal scorers from playoff fixtures.
func GetPlayoffTopGoalScorers(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*LeagueTopGoalScorer, error) {
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
if leagueID == 0 {
return nil, errors.New("leagueID not provided")
}
var stats []*LeagueTopGoalScorer
err := tx.NewRaw(`
SELECT
agg.player_id,
agg.player_name,
COALESCE(tr.team_id, 0) AS team_id,
COALESCE(t.name, '') AS team_name,
COALESCE(t.color, '') AS team_color,
agg.total_goals,
agg.total_periods_played,
agg.total_shots
FROM (
SELECT
frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name,
COALESCE(SUM(frps.goals), 0) AS total_goals,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.shots), 0) AS total_shots
FROM fixture_result_player_stats frps
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
JOIN fixtures f ON f.id = fr.fixture_id
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_id = ?
AND f.round < 0
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
ORDER BY total_goals DESC, total_periods_played ASC, total_shots ASC
LIMIT 10
) agg
LEFT JOIN team_rosters tr
ON tr.player_id = agg.player_id
AND tr.season_id = ?
AND tr.league_id = ?
LEFT JOIN teams t ON t.id = tr.team_id
ORDER BY agg.total_goals DESC, agg.total_periods_played ASC, agg.total_shots ASC
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// GetPlayoffTopAssisters returns the top 10 assisters from playoff fixtures.
func GetPlayoffTopAssisters(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*LeagueTopAssister, error) {
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
if leagueID == 0 {
return nil, errors.New("leagueID not provided")
}
var stats []*LeagueTopAssister
err := tx.NewRaw(`
SELECT
agg.player_id,
agg.player_name,
COALESCE(tr.team_id, 0) AS team_id,
COALESCE(t.name, '') AS team_name,
COALESCE(t.color, '') AS team_color,
agg.total_assists,
agg.total_periods_played,
agg.total_primary_assists
FROM (
SELECT
frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name,
COALESCE(SUM(frps.assists), 0) AS total_assists,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.primary_assists), 0) AS total_primary_assists
FROM fixture_result_player_stats frps
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
JOIN fixtures f ON f.id = fr.fixture_id
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_id = ?
AND f.round < 0
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
ORDER BY total_assists DESC, total_periods_played ASC, total_primary_assists DESC
LIMIT 10
) agg
LEFT JOIN team_rosters tr
ON tr.player_id = agg.player_id
AND tr.season_id = ?
AND tr.league_id = ?
LEFT JOIN teams t ON t.id = tr.team_id
ORDER BY agg.total_assists DESC, agg.total_periods_played ASC, agg.total_primary_assists DESC
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// GetPlayoffTopSavers returns the top 10 savers from playoff fixtures.
func GetPlayoffTopSavers(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*LeagueTopSaver, error) {
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
if leagueID == 0 {
return nil, errors.New("leagueID not provided")
}
var stats []*LeagueTopSaver
err := tx.NewRaw(`
SELECT
agg.player_id,
agg.player_name,
COALESCE(tr.team_id, 0) AS team_id,
COALESCE(t.name, '') AS team_name,
COALESCE(t.color, '') AS team_color,
agg.total_saves,
agg.total_periods_played,
agg.total_blocks
FROM (
SELECT
frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name,
COALESCE(SUM(frps.saves), 0) AS total_saves,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.blocks), 0) AS total_blocks
FROM fixture_result_player_stats frps
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
JOIN fixtures f ON f.id = fr.fixture_id
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_id = ?
AND f.round < 0
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
ORDER BY total_saves DESC, total_periods_played ASC, total_blocks DESC
LIMIT 10
) agg
LEFT JOIN team_rosters tr
ON tr.player_id = agg.player_id
AND tr.season_id = ?
AND tr.league_id = ?
LEFT JOIN teams t ON t.id = tr.team_id
ORDER BY agg.total_saves DESC, agg.total_periods_played ASC, agg.total_blocks DESC
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}

View File

@@ -327,9 +327,6 @@
max-width: 96rem;
}
}
.mx-1 {
margin-inline: calc(var(--spacing) * 1);
}
.mx-auto {
margin-inline: auto;
}
@@ -472,9 +469,6 @@
.h-9 {
height: calc(var(--spacing) * 9);
}
.h-10 {
height: calc(var(--spacing) * 10);
}
.h-12 {
height: calc(var(--spacing) * 12);
}
@@ -559,9 +553,6 @@
.w-14 {
width: calc(var(--spacing) * 14);
}
.w-16 {
width: calc(var(--spacing) * 16);
}
.w-20 {
width: calc(var(--spacing) * 20);
}
@@ -679,9 +670,6 @@
--tw-scale-z: 100%;
scale: var(--tw-scale-x) var(--tw-scale-y);
}
.rotate-180 {
rotate: 180deg;
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
@@ -766,9 +754,6 @@
.justify-end {
justify-content: flex-end;
}
.gap-0 {
gap: calc(var(--spacing) * 0);
}
.gap-0\.5 {
gap: calc(var(--spacing) * 0.5);
}
@@ -1593,11 +1578,6 @@
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.transition-transform {
transition-property: transform, translate, scale, rotate;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.duration-150 {
--tw-duration: 150ms;
transition-duration: 150ms;
@@ -2436,16 +2416,6 @@
gap: calc(var(--spacing) * 12);
}
}
.lg\:divide-x {
@media (width >= 64rem) {
:where(& > :not(:last-child)) {
--tw-divide-x-reverse: 0;
border-inline-style: var(--tw-border-style);
border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));
border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));
}
}
}
.lg\:px-8 {
@media (width >= 64rem) {
padding-inline: calc(var(--spacing) * 8);

View File

@@ -22,7 +22,7 @@ import (
// SeasonLeagueFinalsPage renders the finals tab of a season league page.
// Displays different content based on season status:
// - In Progress: "Regular Season in Progress" with optional "Begin Finals" button
// - Finals Soon/Finals/Completed: The playoff bracket + finals stats
// - Finals Soon/Finals/Completed: The playoff bracket
func SeasonLeagueFinalsPage(
s *hws.Server,
conn *db.DB,
@@ -34,10 +34,6 @@ func SeasonLeagueFinalsPage(
var season *db.Season
var league *db.League
var bracket *db.PlayoffBracket
var topGoals []*db.LeagueTopGoalScorer
var topAssists []*db.LeagueTopAssister
var topSaves []*db.LeagueTopSaver
var allStats []*db.LeaguePlayerStats
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
@@ -58,35 +54,15 @@ func SeasonLeagueFinalsPage(
return false, errors.Wrap(err, "db.GetPlayoffBracket")
}
// Load playoff stats if bracket exists
if bracket != nil {
topGoals, err = db.GetPlayoffTopGoalScorers(ctx, tx, season.ID, league.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayoffTopGoalScorers")
}
topAssists, err = db.GetPlayoffTopAssisters(ctx, tx, season.ID, league.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayoffTopAssisters")
}
topSaves, err = db.GetPlayoffTopSavers(ctx, tx, season.ID, league.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayoffTopSavers")
}
allStats, err = db.GetPlayoffPlayerStats(ctx, tx, season.ID, league.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayoffPlayerStats")
}
}
return true, nil
}); !ok {
return
}
if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league, bracket, topGoals, topAssists, topSaves, allStats), s, r, w)
renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league, bracket), s, r, w)
} else {
renderSafely(seasonsview.SeasonLeagueFinals(season, league, bracket, topGoals, topAssists, topSaves, allStats), s, r, w)
renderSafely(seasonsview.SeasonLeagueFinals(season, league, bracket), s, r, w)
}
})
}

View File

@@ -1,306 +0,0 @@
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)
}
})
}

View File

@@ -1,94 +0,0 @@
package handlers
import (
"context"
"net/http"
"strconv"
"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"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// ForfeitSeries handles POST /series/{series_id}/forfeit
// Forfeits a playoff series. The forfeiting team loses and the opponent wins
// and advances through the bracket. Requires playoffs.manage permission.
func ForfeitSeries(
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
}
forfeitTeamStr := getter.String("forfeit_team").TrimSpace().Required().Value
forfeitReason := getter.String("forfeit_reason").TrimSpace().Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
// Validate forfeit_team is "team1" or "team2"
if forfeitTeamStr != "team1" && forfeitTeamStr != "team2" {
notify.Warn(s, w, r, "Invalid Team", "Please select which team is forfeiting.", nil)
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 {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetPlayoffSeriesByID"))
return false, nil
}
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
}
if series == nil {
respond.NotFound(w, errors.New("series not found"))
return false, nil
}
// Resolve the forfeit team ID
var forfeitTeamID int
if forfeitTeamStr == "team1" {
if series.Team1ID == nil {
notify.Warn(s, w, r, "No Team", "Team 1 is not assigned.", nil)
return false, nil
}
forfeitTeamID = *series.Team1ID
} else {
if series.Team2ID == nil {
notify.Warn(s, w, r, "No Team", "Team 2 is not assigned.", nil)
return false, nil
}
forfeitTeamID = *series.Team2ID
}
err = db.ForfeitSeries(ctx, tx, seriesID, forfeitTeamID, forfeitReason, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Forfeit", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.ForfeitSeries")
}
return true, nil
}); !ok {
return
}
notify.SuccessWithDelay(s, w, r, "Series Forfeited", "The series has been forfeited and the opponent advances.", nil)
respond.HXRedirect(w, "/series/%d", seriesID)
})
}

View File

@@ -1,502 +0,0 @@
package handlers
import (
"context"
"io"
"net/http"
"strconv"
"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/view/seasonsview"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// SeriesUploadResultPage renders the upload form for series match logs
func SeriesUploadResultPage(
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
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
}
// Check for existing pending results
hasPending, err := db.HasPendingSeriesResults(ctx, tx, seriesID)
if err != nil {
return false, errors.Wrap(err, "db.HasPendingSeriesResults")
}
if hasPending {
throw.BadRequest(s, w, r, "Pending results already exist for this series. Discard them first to re-upload.", nil)
return false, nil
}
return true, nil
}); !ok {
return
}
renderSafely(seasonsview.SeriesUploadResultPage(series), s, r, w)
})
}
// SeriesUploadResults handles POST /series/{series_id}/results/upload
// Parses match logs for all games, creates fixtures + results.
func SeriesUploadResults(
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
}
// Parse multipart form
err = r.ParseMultipartForm(maxUploadSize * 5) // up to 5 games worth
if err != nil {
notify.Warn(s, w, r, "Upload Failed", "Could not parse uploaded files.", nil)
return
}
gameCountStr := r.FormValue("game_count")
gameCount, err := strconv.Atoi(gameCountStr)
if err != nil || gameCount < 1 {
notify.Warn(s, w, r, "Invalid Input", "Please select a valid number of games.", nil)
return
}
// Parse all game logs
type gameLogs struct {
Logs [3]*slapshotapi.MatchLog
}
allGameLogs := make([]*gameLogs, gameCount)
for g := 1; g <= gameCount; g++ {
gl := &gameLogs{}
for p := 1; p <= 3; p++ {
fieldName := "game_" + strconv.Itoa(g) + "_period_" + strconv.Itoa(p)
file, _, err := r.FormFile(fieldName)
if err != nil {
notify.Warn(s, w, r, "Missing File",
"All 3 period files are required for Game "+strconv.Itoa(g)+". Missing period "+strconv.Itoa(p)+".", nil)
return
}
defer func() { _ = file.Close() }()
data, err := io.ReadAll(file)
if err != nil {
notify.Warn(s, w, r, "Read Error", "Could not read file: "+fieldName, nil)
return
}
log, err := slapshotapi.ParseMatchLog(data)
if err != nil {
notify.Warn(s, w, r, "Parse Error",
"Could not parse Game "+strconv.Itoa(g)+" Period "+strconv.Itoa(p)+": "+err.Error(), nil)
return
}
gl.Logs[p-1] = log
}
allGameLogs[g-1] = gl
}
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
}
// Validate game count
maxGames := series.MatchesToWin*2 - 1
if gameCount < series.MatchesToWin || gameCount > maxGames {
notify.Warn(s, w, r, "Invalid Game Count",
"Game count must be between "+strconv.Itoa(series.MatchesToWin)+" and "+strconv.Itoa(maxGames)+".", nil)
return false, nil
}
// Check for existing pending results
hasPending, err := db.HasPendingSeriesResults(ctx, tx, seriesID)
if err != nil {
return false, errors.Wrap(err, "db.HasPendingSeriesResults")
}
if hasPending {
notify.Warn(s, w, r, "Results Exist", "Pending results already exist. Discard them first.", nil)
return false, nil
}
audit := db.NewAuditFromRequest(r)
user := db.CurrentUser(ctx)
// Process each game
team1Wins := 0
team2Wins := 0
for g := 0; g < gameCount; g++ {
gl := allGameLogs[g]
logs := []*slapshotapi.MatchLog{gl.Logs[0], gl.Logs[1], gl.Logs[2]}
matchNumber := g + 1
// Check if series is already decided
if team1Wins >= series.MatchesToWin || team2Wins >= series.MatchesToWin {
notify.Warn(s, w, r, "Too Many Games",
"The series was already decided before Game "+strconv.Itoa(matchNumber)+". Reduce the game count.", nil)
return false, nil
}
// Detect tampering
tamperingDetected, tamperingReason, err := slapshotapi.DetectTampering(logs)
if err != nil {
notify.Warn(s, w, r, "Validation Error",
"Game "+strconv.Itoa(matchNumber)+" tampering check failed: "+err.Error(), nil)
return false, nil
}
// Create fixture for this game
fixture, _, err := db.CreatePlayoffGameFixture(ctx, tx, series, matchNumber, audit)
if err != nil {
return false, errors.Wrap(err, "db.CreatePlayoffGameFixture")
}
// Collect game_user_ids
gameUserIDSet := map[string]bool{}
for _, log := range logs {
for _, p := range log.Players {
gameUserIDSet[p.GameUserID] = true
}
}
gameUserIDs := make([]string, 0, len(gameUserIDSet))
for id := range gameUserIDSet {
gameUserIDs = append(gameUserIDs, id)
}
// Map players
playerLookup, err := MapGameUserIDsToPlayers(ctx, tx, gameUserIDs, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return false, errors.Wrap(err, "MapGameUserIDsToPlayers")
}
// Determine orientation
allPlayers := logs[2].Players
fixtureHomeIsLogsHome, _, err := DetermineTeamOrientation(ctx, tx, fixture, allPlayers, playerLookup)
if err != nil {
notify.Warn(s, w, r, "Orientation Error",
"Game "+strconv.Itoa(matchNumber)+": Could not determine team orientation: "+err.Error(), nil)
return false, nil
}
// Build result
finalLog := logs[2]
winner := finalLog.Winner
homeScore := finalLog.Score.Home
awayScore := finalLog.Score.Away
if !fixtureHomeIsLogsHome {
switch winner {
case "home":
winner = "away"
case "away":
winner = "home"
}
homeScore, awayScore = awayScore, homeScore
}
periodsEnabled := finalLog.PeriodsEnabled == "True"
customMercyRule, _ := strconv.Atoi(finalLog.CustomMercyRule)
matchLength, _ := strconv.Atoi(finalLog.MatchLength)
var tamperingReasonPtr *string
if tamperingDetected {
tamperingReasonPtr = &tamperingReason
}
result := &db.FixtureResult{
FixtureID: fixture.ID,
Winner: winner,
HomeScore: homeScore,
AwayScore: awayScore,
MatchType: finalLog.Type,
Arena: finalLog.Arena,
EndReason: finalLog.EndReason,
PeriodsEnabled: periodsEnabled,
CustomMercyRule: customMercyRule,
MatchLength: matchLength,
UploadedByUserID: user.ID,
Finalized: false,
TamperingDetected: tamperingDetected,
TamperingReason: tamperingReasonPtr,
}
// Build player stats
playerStats := []*db.FixtureResultPlayerStats{}
for periodIdx, log := range logs {
periodNum := periodIdx + 1
for _, p := range log.Players {
team := p.Team
if !fixtureHomeIsLogsHome {
if team == "home" {
team = "away"
} else {
team = "home"
}
}
var playerID *int
var teamID *int
if lookup, ok := playerLookup[p.GameUserID]; ok && lookup.Found {
playerID = &lookup.Player.ID
if !lookup.Unmapped {
teamID = &lookup.TeamID
}
}
stat := &db.FixtureResultPlayerStats{
PeriodNum: periodNum,
PlayerID: playerID,
PlayerGameUserID: p.GameUserID,
PlayerUsername: p.Username,
TeamID: teamID,
Team: team,
Goals: FloatToIntPtr(p.Stats.Goals),
Assists: FloatToIntPtr(p.Stats.Assists),
PrimaryAssists: FloatToIntPtr(p.Stats.PrimaryAssists),
SecondaryAssists: FloatToIntPtr(p.Stats.SecondaryAssists),
Saves: FloatToIntPtr(p.Stats.Saves),
Blocks: FloatToIntPtr(p.Stats.Blocks),
Shots: FloatToIntPtr(p.Stats.Shots),
Turnovers: FloatToIntPtr(p.Stats.Turnovers),
Takeaways: FloatToIntPtr(p.Stats.Takeaways),
Passes: FloatToIntPtr(p.Stats.Passes),
PossessionTimeSec: FloatToIntPtr(p.Stats.PossessionTime),
FaceoffsWon: FloatToIntPtr(p.Stats.FaceoffsWon),
FaceoffsLost: FloatToIntPtr(p.Stats.FaceoffsLost),
PostHits: FloatToIntPtr(p.Stats.PostHits),
OvertimeGoals: FloatToIntPtr(p.Stats.OvertimeGoals),
GameWinningGoals: FloatToIntPtr(p.Stats.GameWinningGoals),
Score: FloatToIntPtr(p.Stats.Score),
ContributedGoals: FloatToIntPtr(p.Stats.ContributedGoals),
ConcededGoals: FloatToIntPtr(p.Stats.ConcededGoals),
GamesPlayed: FloatToIntPtr(p.Stats.GamesPlayed),
Wins: FloatToIntPtr(p.Stats.Wins),
Losses: FloatToIntPtr(p.Stats.Losses),
OvertimeWins: FloatToIntPtr(p.Stats.OvertimeWins),
OvertimeLosses: FloatToIntPtr(p.Stats.OvertimeLosses),
Ties: FloatToIntPtr(p.Stats.Ties),
Shutouts: FloatToIntPtr(p.Stats.Shutouts),
ShutoutsAgainst: FloatToIntPtr(p.Stats.ShutsAgainst),
HasMercyRuled: FloatToIntPtr(p.Stats.HasMercyRuled),
WasMercyRuled: FloatToIntPtr(p.Stats.WasMercyRuled),
PeriodsPlayed: FloatToIntPtr(p.Stats.PeriodsPlayed),
}
playerStats = append(playerStats, stat)
}
}
// Mark free agents
for _, ps := range playerStats {
if ps.PlayerID == nil {
continue
}
isFA, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, *ps.PlayerID)
if err != nil {
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
}
if isFA {
ps.IsFreeAgent = true
}
}
// Insert result
_, err = db.InsertFixtureResult(ctx, tx, result, playerStats, audit)
if err != nil {
return false, errors.Wrap(err, "db.InsertFixtureResult")
}
// Track wins: home = team1, away = team2
if winner == "home" {
team1Wins++
} else {
team2Wins++
}
}
// Validate that the series result is valid
if team1Wins < series.MatchesToWin && team2Wins < series.MatchesToWin {
notify.Warn(s, w, r, "Incomplete Series",
"Neither team has enough wins to decide the series. More games are needed.", nil)
return false, nil
}
return true, nil
}); !ok {
return
}
respond.HXRedirect(w, "/series/%d/results/review", seriesID)
})
}
// SeriesReviewResults handles GET /series/{series_id}/results/review
func SeriesReviewResults(
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 gameResults []*seasonsview.SeriesGameResult
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
}
// Build game results from matches
for _, match := range series.Matches {
if match.FixtureID == nil {
continue
}
result, err := db.GetPendingFixtureResult(ctx, tx, *match.FixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetPendingFixtureResult")
}
if result == nil {
continue
}
gr := &seasonsview.SeriesGameResult{
GameNumber: match.MatchNumber,
Result: result,
}
// Build unmapped players and FA warnings
for _, ps := range result.PlayerStats {
if ps.PeriodNum != 3 {
continue
}
if ps.PlayerID == nil {
gr.UnmappedPlayers = append(gr.UnmappedPlayers,
ps.PlayerGameUserID+" ("+ps.PlayerUsername+")")
} else if ps.IsFreeAgent {
gr.FreeAgentWarnings = append(gr.FreeAgentWarnings, seasonsview.FreeAgentWarning{
Name: ps.PlayerUsername,
Reason: "free agent in playoff match",
})
}
}
gameResults = append(gameResults, gr)
}
if len(gameResults) == 0 {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return true, nil
}); !ok {
return
}
renderSafely(seasonsview.SeriesReviewResultPage(series, gameResults), s, r, w)
})
}
// SeriesFinalizeResults handles POST /series/{series_id}/results/finalize
func SeriesFinalizeResults(
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
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
_, err := db.FinalizeSeriesResults(ctx, tx, seriesID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Finalize", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.FinalizeSeriesResults")
}
return true, nil
}); !ok {
return
}
notify.SuccessWithDelay(s, w, r, "Series Finalized", "All game results have been finalized and the series is complete.", nil)
respond.HXRedirect(w, "/series/%d", seriesID)
})
}
// SeriesDiscardResults handles POST /series/{series_id}/results/discard
func SeriesDiscardResults(
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
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
err := db.DeleteSeriesResults(ctx, tx, seriesID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Discard", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.DeleteSeriesResults")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Results Discarded", "All uploaded results have been discarded. You can upload new logs.", nil)
respond.HXRedirect(w, "/series/%d", seriesID)
})
}

View File

@@ -1,410 +0,0 @@
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)
})
}

View File

@@ -124,9 +124,6 @@ func refreshToken(
case "expired", "expiring":
newtoken, err := discordAPI.RefreshToken(token.Convert())
if err != nil {
if strings.Contains(err.Error(), "invalid_grant") {
return false, nil
}
return false, errors.Wrap(err, "discordAPI.RefreshToken")
}
err = user.UpdateDiscordToken(ctx, tx, newtoken)

View File

@@ -329,100 +329,6 @@ func addRoutes(
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.ForfeitFixture(s, conn)),
},
// Series detail page routes
{
Path: "/series/{series_id}",
Method: hws.MethodGET,
Handler: handlers.SeriesDetailPage(s, conn),
},
{
Path: "/series/{series_id}/overview",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.SeriesDetailOverviewPage(s, conn),
},
{
Path: "/series/{series_id}/preview",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.SeriesDetailPreviewPage(s, conn),
},
{
Path: "/series/{series_id}/analysis",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.SeriesDetailAnalysisPage(s, conn),
},
{
Path: "/series/{series_id}/scheduling",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.SeriesDetailSchedulePage(s, conn),
},
// Series scheduling routes
{
Path: "/series/{series_id}/schedule",
Method: hws.MethodPOST,
Handler: handlers.ProposeSeriesSchedule(s, conn),
},
{
Path: "/series/{series_id}/schedule/{schedule_id}/accept",
Method: hws.MethodPOST,
Handler: handlers.AcceptSeriesSchedule(s, conn),
},
{
Path: "/series/{series_id}/schedule/{schedule_id}/reject",
Method: hws.MethodPOST,
Handler: handlers.RejectSeriesSchedule(s, conn),
},
{
Path: "/series/{series_id}/schedule/{schedule_id}/withdraw",
Method: hws.MethodPOST,
Handler: handlers.WithdrawSeriesScheduleHandler(s, conn),
},
{
Path: "/series/{series_id}/schedule/postpone",
Method: hws.MethodPOST,
Handler: handlers.PostponeSeriesSchedule(s, conn),
},
{
Path: "/series/{series_id}/schedule/reschedule",
Method: hws.MethodPOST,
Handler: handlers.RescheduleSeriesHandler(s, conn),
},
{
Path: "/series/{series_id}/schedule/cancel",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.CancelSeriesScheduleHandler(s, conn)),
},
// Series result management routes
{
Path: "/series/{series_id}/results/upload",
Method: hws.MethodGET,
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesUploadResultPage(s, conn)),
},
{
Path: "/series/{series_id}/results/upload",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesUploadResults(s, conn)),
},
{
Path: "/series/{series_id}/results/review",
Method: hws.MethodGET,
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesReviewResults(s, conn)),
},
{
Path: "/series/{series_id}/results/finalize",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesFinalizeResults(s, conn)),
},
{
Path: "/series/{series_id}/results/discard",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesDiscardResults(s, conn)),
},
// Series forfeit route
{
Path: "/series/{series_id}/forfeit",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.ForfeitSeries(s, conn)),
},
}
playerRoutes := []hws.Route{

View File

@@ -520,14 +520,6 @@ templ fixtureResultDisplay(fixture *db.Fixture, result *db.FixtureResult) {
}
templ fixtureUploadPrompt(fixture *db.Fixture) {
<div
x-data="{
open: false,
forfeitType: 'outright',
forfeitTeam: '',
forfeitReason: '',
}"
>
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
<div class="text-4xl mb-3">📋</div>
<p class="text-lg text-text font-medium mb-2">No Result Uploaded</p>
@@ -542,7 +534,7 @@ templ fixtureUploadPrompt(fixture *db.Fixture) {
</a>
<button
type="button"
@click="open = true"
@click="$dispatch('open-forfeit-modal')"
class="inline-block px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
@@ -551,11 +543,17 @@ templ fixtureUploadPrompt(fixture *db.Fixture) {
</div>
</div>
@forfeitModal(fixture)
</div>
}
templ forfeitModal(fixture *db.Fixture) {
<div
x-data="{
open: false,
forfeitType: 'outright',
forfeitTeam: '',
forfeitReason: '',
}"
@open-forfeit-modal.window="open = true"
x-show="open"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
@@ -1205,7 +1203,7 @@ templ fixtureScheduleStatus(
<div class="flex justify-center mt-4">
<button
type="button"
onclick={ templ.JSUnsafeFuncCall("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Withdraw Proposal', message: 'Are you sure you want to withdraw your proposed time?', action: () => htmx.ajax('POST', '/fixtures/" + fmt.Sprint(fixture.ID) + "/schedule/" + fmt.Sprint(current.ID) + "/withdraw', { swap: 'none' }) } }))") }
@click={ "window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Withdraw Proposal', message: 'Are you sure you want to withdraw your proposed time?', action: () => htmx.ajax('POST', '/fixtures/" + fmt.Sprint(fixture.ID) + "/schedule/" + fmt.Sprint(current.ID) + "/withdraw', { swap: 'none' }) } }))" }
class="px-4 py-2 bg-surface1 hover:bg-surface2 text-text rounded-lg
font-medium transition hover:cursor-pointer"
>
@@ -1432,7 +1430,7 @@ templ fixtureScheduleActions(
</div>
<button
type="button"
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("if (!this.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Postpone Fixture', message: 'Are you sure you want to postpone this fixture? The current schedule will be cleared.', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/postpone', { swap: 'none', values: { reschedule_reason: this.parentElement.querySelector('select').value } }) } }))", fixture.ID)) }
@click={ fmt.Sprintf("if (!$el.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Postpone Fixture', message: 'Are you sure you want to postpone this fixture? The current schedule will be cleared.', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/postpone', { swap: 'none', values: { reschedule_reason: $el.parentElement.querySelector('select').value } }) } }))", fixture.ID) }
class="w-full px-4 py-2 bg-peach hover:bg-peach/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
@@ -1459,7 +1457,7 @@ templ fixtureScheduleActions(
</div>
<button
type="button"
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("if (!this.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Declare Forfeit', message: 'This action is IRREVERSIBLE. The fixture schedule will be permanently cancelled. Are you sure?', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/cancel', { swap: 'none', values: { reschedule_reason: this.parentElement.querySelector('select').value } }) } }))", fixture.ID)) }
@click={ fmt.Sprintf("if (!$el.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Declare Forfeit', message: 'This action is IRREVERSIBLE. The fixture schedule will be permanently cancelled. Are you sure?', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/cancel', { swap: 'none', values: { reschedule_reason: $el.parentElement.querySelector('select').value } }) } }))", fixture.ID) }
class="w-full px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>

View File

@@ -162,7 +162,7 @@ templ FixtureReviewResultPage(
</form>
<button
type="button"
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Discard Result', message: 'Are you sure you want to discard this result? You will need to re-upload the match logs.', action: () => htmx.ajax('POST', '/fixtures/%d/results/discard', { swap: 'none' }) } }))", fixture.ID)) }
@click={ fmt.Sprintf("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Discard Result', message: 'Are you sure you want to discard this result? You will need to re-upload the match logs.', action: () => htmx.ajax('POST', '/fixtures/%d/results/discard', { swap: 'none' }) } }))", fixture.ID) }
class="px-6 py-3 bg-red hover:bg-red/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer text-lg"
>

View File

@@ -35,7 +35,7 @@ templ LeaguesSection(season *db.Season, allLeagues []*db.League) {
if canRemoveLeague {
<button
type="button"
onclick={ templ.JSUnsafeFuncCall("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Remove League', message: 'Are you sure you want to remove " + league.Name + " from this season?', action: () => htmx.ajax('DELETE', '/seasons/" + season.ShortName + "/leagues/" + league.ShortName + "', { target: '#leagues-section', swap: 'outerHTML' }) } }))") }
@click={ "window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Remove League', message: 'Are you sure you want to remove " + league.Name + " from this season?', action: () => htmx.ajax('DELETE', '/seasons/" + season.ShortName + "/leagues/" + league.ShortName + "', { target: '#leagues-section', swap: 'outerHTML' }) } }))" }
class="text-red hover:text-red/75 hover:cursor-pointer ml-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -44,15 +44,6 @@ templ PlayoffBracketView(season *db.Season, league *db.League, bracket *db.Playo
</div>
</div>
<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>
}
// ──────────────────────────────────────────────
@@ -198,21 +189,11 @@ templ bracket10to15(season *db.Season, league *db.League, bracket *db.PlayoffBra
// ──────────────────────────────────────────────
templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) {
{{
hasTeams := series.Team1 != nil || series.Team2 != nil
seriesURL := fmt.Sprintf("/series/%d", series.ID)
isForfeit := series.IsForfeit
}}
<div
data-series={ fmt.Sprint(series.SeriesNumber) }
if hasTeams {
data-series-url={ seriesURL }
}
class={ "bg-surface0 border rounded-lg overflow-hidden",
templ.KV("border-blue/50", series.Status == db.SeriesStatusInProgress),
templ.KV("border-red/50", isForfeit),
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress && !isForfeit),
templ.KV("hover:bg-surface1 hover:cursor-pointer transition", hasTeams) }
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress) }
>
<!-- Series Header -->
<div class="bg-mantle px-3 py-1.5 flex items-center justify-between border-b border-surface1">
@@ -220,24 +201,17 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
<span class="text-xs font-semibold text-subtext0">{ series.Label }</span>
@seriesFormatBadge(series.MatchesToWin)
</div>
<div class="flex items-center gap-1">
if isForfeit {
<span class="px-1.5 py-0.5 bg-red/20 text-red rounded text-xs font-bold">
FF
</span>
}
@seriesStatusBadge(series.Status)
</div>
</div>
<!-- Teams -->
<div class="divide-y divide-surface1">
@seriesTeamRow(season, league, series.Team1, series.Team1Seed, series.Team1Wins,
series.WinnerTeamID, series.ForfeitTeamID, series.MatchesToWin)
series.WinnerTeamID, series.MatchesToWin)
@seriesTeamRow(season, league, series.Team2, series.Team2Seed, series.Team2Wins,
series.WinnerTeamID, series.ForfeitTeamID, series.MatchesToWin)
series.WinnerTeamID, series.MatchesToWin)
</div>
<!-- Series Score -->
if series.MatchesToWin > 1 && !isForfeit {
if series.MatchesToWin > 1 {
<div class="bg-mantle px-3 py-1 text-center text-xs text-subtext0 border-t border-surface1">
{ fmt.Sprint(series.Team1Wins) } - { fmt.Sprint(series.Team2Wins) }
</div>
@@ -245,21 +219,16 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
</div>
}
templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *int, wins int, winnerID *int, forfeitTeamID *int, matchesToWin int) {
templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *int, wins int, winnerID *int, matchesToWin int) {
{{
isWinner := false
if team != nil && winnerID != nil {
isWinner = team.ID == *winnerID
}
isForfeiter := false
if team != nil && forfeitTeamID != nil {
isForfeiter = team.ID == *forfeitTeamID
}
isTBD := team == nil
}}
<div class={ "flex items-center justify-between px-3 py-2",
templ.KV("bg-green/5", isWinner && !isForfeiter),
templ.KV("bg-red/5", isForfeiter) }>
templ.KV("bg-green/5", isWinner) }>
<div class="flex items-center gap-2 min-w-0">
if seed != nil {
<span class="text-xs font-mono text-subtext0 w-4 text-right flex-shrink-0">
@@ -274,14 +243,12 @@ templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *i
<div class="truncate">
@links.TeamLinkInSeason(team, season, league)
</div>
if isForfeiter {
<span class="text-red text-xs font-bold flex-shrink-0">FF</span>
} else if isWinner {
if isWinner {
<span class="text-green text-xs flex-shrink-0">✓</span>
}
}
</div>
if matchesToWin > 1 && forfeitTeamID == nil {
if matchesToWin > 1 {
<span class={ "text-sm font-mono flex-shrink-0 ml-2",
templ.KV("text-text", !isWinner),
templ.KV("text-green font-bold", isWinner) }>

View File

@@ -5,43 +5,20 @@ import "git.haelnorr.com/h/oslstats/internal/contexts"
import "git.haelnorr.com/h/oslstats/internal/permissions"
import "fmt"
templ SeasonLeagueFinalsPage(
season *db.Season,
league *db.League,
bracket *db.PlayoffBracket,
topGoals []*db.LeagueTopGoalScorer,
topAssists []*db.LeagueTopAssister,
topSaves []*db.LeagueTopSaver,
allStats []*db.LeaguePlayerStats,
) {
templ SeasonLeagueFinalsPage(season *db.Season, league *db.League, bracket *db.PlayoffBracket) {
@SeasonLeagueLayout("finals", season, league) {
@SeasonLeagueFinals(season, league, bracket, topGoals, topAssists, topSaves, allStats)
@SeasonLeagueFinals(season, league, bracket)
}
}
templ SeasonLeagueFinals(
season *db.Season,
league *db.League,
bracket *db.PlayoffBracket,
topGoals []*db.LeagueTopGoalScorer,
topAssists []*db.LeagueTopAssister,
topSaves []*db.LeagueTopSaver,
allStats []*db.LeaguePlayerStats,
) {
templ SeasonLeagueFinals(season *db.Season, league *db.League, bracket *db.PlayoffBracket) {
{{
permCache := contexts.Permissions(ctx)
canManagePlayoffs := permCache != nil && permCache.HasPermission(permissions.PlayoffsManage)
hasStats := len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 || len(allStats) > 0
}}
<div id="finals-content">
if bracket != nil {
@PlayoffBracketView(season, league, bracket)
<!-- Finals Stats Section -->
if hasStats {
<div class="mt-8">
@finalsStatsSection(season, league, topGoals, topAssists, topSaves, allStats)
</div>
}
} else if canManagePlayoffs {
@finalsNotYetConfigured(season, league)
} else {
@@ -50,43 +27,6 @@ templ SeasonLeagueFinals(
</div>
}
templ finalsStatsSection(
season *db.Season,
league *db.League,
topGoals []*db.LeagueTopGoalScorer,
topAssists []*db.LeagueTopAssister,
topSaves []*db.LeagueTopSaver,
allStats []*db.LeaguePlayerStats,
) {
<script src="/static/js/sortable-table.js"></script>
<div class="space-y-8">
<div class="flex items-center gap-2">
<span class="text-yellow">&#9733;</span>
<h2 class="text-xl font-bold text-text">Finals Stats</h2>
</div>
<!-- Trophy Leaders -->
if len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 {
<div class="space-y-4">
<h3 class="text-lg font-bold text-text text-center">Trophy Leaders</h3>
<div class="flex flex-col items-center gap-6 w-full">
<div class="flex flex-col lg:flex-row gap-6 justify-center items-stretch w-full lg:w-auto">
@topGoalScorersTable(season, league, topGoals)
@topAssistersTable(season, league, topAssists)
</div>
@topSaversTable(season, league, topSaves)
</div>
</div>
}
<!-- All Finals Stats -->
if len(allStats) > 0 {
<div class="space-y-4">
<h3 class="text-lg font-bold text-text text-center">All Finals Stats</h3>
@allStatsTable(season, league, allStats)
</div>
}
</div>
}
templ finalsNotYetConfigured(season *db.Season, league *db.League) {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<div class="mb-4">

View File

@@ -1,820 +0,0 @@
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)
}}
@seriesOverviewTab(series, currentSchedule, rosters, canSchedule, canManage, 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,
canManage bool,
userTeamID int,
) {
{{
isCompleted := series.Status == db.SeriesStatusCompleted
isBye := series.Status == db.SeriesStatusBye
bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil
showUploadPrompt := canManage && !isCompleted && !isBye && bothTeamsAssigned
}}
<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>
<!-- Upload Prompt (for admins when series is in progress) -->
if showUploadPrompt {
@seriesUploadPrompt(series)
}
<!-- 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 seriesUploadPrompt(series *db.PlayoffSeries) {
{{
// Check if there are pending results waiting for review
hasPendingMatches := false
for _, match := range series.Matches {
if match.FixtureID != nil && match.Status == "pending" {
hasPendingMatches = true
break
}
}
}}
<div
x-data="{ open: false }"
class="bg-mantle border border-surface1 rounded-lg p-6 text-center"
>
if hasPendingMatches {
<div class="text-4xl mb-3">📋</div>
<p class="text-lg text-text font-medium mb-2">Results Pending Review</p>
<p class="text-sm text-subtext1 mb-4">Uploaded results are waiting to be reviewed and finalized.</p>
<div class="flex items-center justify-center gap-3">
<a
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/review", series.ID)) }
class="inline-block px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Review Results
</a>
<button
type="button"
@click="open = true"
class="inline-block px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Forfeit Series
</button>
</div>
} else {
<div class="text-4xl mb-3">📋</div>
<p class="text-lg text-text font-medium mb-2">No Results Uploaded</p>
<p class="text-sm text-subtext1 mb-4">Upload match log files to record the series results.</p>
<div class="flex items-center justify-center gap-3">
<a
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/upload", series.ID)) }
class="inline-block px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Upload Match Logs
</a>
<button
type="button"
@click="open = true"
class="inline-block px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Forfeit Series
</button>
</div>
}
@seriesForfeitModal(series)
</div>
}
templ seriesForfeitModal(series *db.PlayoffSeries) {
{{
team1Name := seriesTeamName(series.Team1)
team2Name := seriesTeamName(series.Team2)
}}
<div
x-show="open"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true"
>
<!-- Background overlay -->
<div
x-show="open"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-base/75 transition-opacity"
@click="open = false"
></div>
<!-- Modal panel -->
<div class="flex min-h-full items-center justify-center p-4">
<div
x-show="open"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="relative transform overflow-hidden rounded-lg bg-mantle border-2 border-surface1 shadow-xl transition-all sm:w-full sm:max-w-lg"
@click.stop
x-data="{ forfeitTeam: '', forfeitReason: '' }"
>
<form
hx-post={ fmt.Sprintf("/series/%d/forfeit", series.ID) }
hx-swap="none"
>
<div class="bg-mantle px-4 pb-4 pt-5 sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red/10 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-red" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"></path>
</svg>
</div>
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left w-full">
<h3 class="text-lg font-semibold leading-6 text-text">Forfeit Series</h3>
<div class="mt-2">
<p class="text-sm text-subtext0 mb-4">
This will forfeit the entire series. The selected team will lose and the opponent
will be declared the winner and advance. Any existing match results will be discarded.
This action is immediate and cannot be undone.
</p>
<!-- Team Selection -->
<div class="space-y-2">
<label class="text-sm font-medium text-text">Which team is forfeiting?</label>
<select
name="forfeit_team"
x-model="forfeitTeam"
class="w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text
focus:border-red focus:outline-none hover:cursor-pointer"
>
<option value="">Select a team...</option>
<option value="team1">{ team1Name }</option>
<option value="team2">{ team2Name }</option>
</select>
</div>
<!-- Reason -->
<div class="mt-4 space-y-2">
<label class="text-sm font-medium text-text">Reason (optional)</label>
<textarea
name="forfeit_reason"
x-model="forfeitReason"
placeholder="Provide a reason for the forfeit..."
class="w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text
focus:border-blue focus:outline-none resize-none"
rows="3"
></textarea>
</div>
</div>
</div>
</div>
</div>
<div class="bg-surface0 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
<button
type="submit"
class="inline-flex w-full justify-center rounded-lg bg-red px-4 py-2 text-sm font-semibold text-mantle shadow-sm hover:bg-red/75 hover:cursor-pointer transition sm:w-auto"
:disabled="forfeitTeam === ''"
:class="forfeitTeam === '' && 'opacity-50 cursor-not-allowed'"
>
Confirm Forfeit
</button>
<button
type="button"
@click="open = false"
class="mt-3 inline-flex w-full justify-center rounded-lg bg-surface1 px-4 py-2 text-sm font-semibold text-text shadow-sm hover:bg-surface2 hover:cursor-pointer transition sm:mt-0 sm:w-auto"
>
Cancel
</button>
</div>
</form>
</div>
</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
isForfeit := series.IsForfeit
forfeitTeamName := ""
if isForfeit && series.ForfeitTeam != nil {
forfeitTeamName = series.ForfeitTeam.Name
}
}}
<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">
if isForfeit {
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
Forfeited
</span>
}
@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">&#127942;</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 && isForfeit {
<span class="px-1.5 py-0.5 bg-red/20 text-red rounded text-xs font-semibold mt-1">
FORFEIT
</span>
} else 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">&#127942;</span>
}
</div>
</div>
if isForfeit && forfeitTeamName != "" {
<div class="text-center mt-2">
<p class="text-sm text-red/80">
{ forfeitTeamName } forfeited the series
</p>
if series.ForfeitReason != nil && *series.ForfeitReason != "" {
<p class="text-xs text-subtext0 mt-1">
{ *series.ForfeitReason }
</p>
}
</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">
&#9733; 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>
}

View File

@@ -1,251 +0,0 @@
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>
}

View File

@@ -1,319 +0,0 @@
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">
&#9733;
</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>
}

View File

@@ -1,374 +0,0 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt"
// SeriesGameResult holds the parsed result for a single game in the series review
type SeriesGameResult struct {
GameNumber int
Result *db.FixtureResult
UnmappedPlayers []string
FreeAgentWarnings []FreeAgentWarning
}
templ SeriesReviewResultPage(
series *db.PlayoffSeries,
gameResults []*SeriesGameResult,
) {
{{
backURL := fmt.Sprintf("/series/%d", series.ID)
team1Name := seriesTeamName(series.Team1)
team2Name := seriesTeamName(series.Team2)
// Calculate series score from the results
team1Wins := 0
team2Wins := 0
for _, gr := range gameResults {
if gr.Result != nil {
if gr.Result.Winner == "home" {
team1Wins++
} else {
team2Wins++
}
}
}
}}
@baseview.Layout(fmt.Sprintf("Review Series Result — %s vs %s", team1Name, team2Name)) {
<div class="max-w-screen-xl 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-6">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-2xl font-bold text-text mb-1">Review Series Result</h1>
<p class="text-sm text-subtext1">
{ team1Name } vs { team2Name }
<span class="text-subtext0 ml-1">{ series.Label }</span>
</p>
</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 Series
</a>
</div>
</div>
</div>
<!-- Series Score Summary -->
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
<h2 class="text-lg font-bold text-text">Series Result</h2>
</div>
<div class="p-6">
<div class="flex items-center justify-center gap-8 py-2">
<div class="flex flex-col items-center text-center">
if series.Team1 != nil && series.Team1.Color != "" {
<div
class="w-10 h-10 rounded-full border-2 border-surface1 mb-2 shrink-0"
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
></div>
}
<p class="text-sm font-medium text-subtext0 mb-1">{ team1Name }</p>
<p class={ "text-5xl font-bold", templ.KV("text-green", team1Wins > team2Wins), templ.KV("text-text", team1Wins <= team2Wins) }>
{ fmt.Sprint(team1Wins) }
</p>
if team1Wins > team2Wins {
<span class="mt-1 px-2 py-0.5 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
}
</div>
<span class="text-3xl text-subtext0 font-light"></span>
<div class="flex flex-col items-center text-center">
if series.Team2 != nil && series.Team2.Color != "" {
<div
class="w-10 h-10 rounded-full border-2 border-surface1 mb-2 shrink-0"
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
></div>
}
<p class="text-sm font-medium text-subtext0 mb-1">{ team2Name }</p>
<p class={ "text-5xl font-bold", templ.KV("text-green", team2Wins > team1Wins), templ.KV("text-text", team2Wins <= team1Wins) }>
{ fmt.Sprint(team2Wins) }
</p>
if team2Wins > team1Wins {
<span class="mt-1 px-2 py-0.5 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
}
</div>
</div>
<p class="text-center text-sm text-subtext1 mt-3">
{ fmt.Sprint(len(gameResults)) } game(s) played
</p>
</div>
</div>
<!-- Per-Game Results -->
<div class="space-y-4 mb-6">
for _, gr := range gameResults {
@seriesReviewGameCard(series, gr)
}
</div>
<!-- Actions -->
<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">Actions</h2>
</div>
<div class="p-6">
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<button
type="button"
hx-post={ fmt.Sprintf("/series/%d/results/finalize", series.ID) }
hx-swap="none"
class="px-6 py-3 bg-green hover:bg-green/75 text-mantle rounded-lg
font-semibold transition hover:cursor-pointer text-lg"
>
Finalize Series
</button>
<button
type="button"
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Discard All Results', message: 'Are you sure you want to discard all uploaded results? You will need to re-upload the match logs for every game.', action: () => htmx.ajax('POST', '/series/%d/results/discard', { swap: 'none' }) } }))", series.ID)) }
class="px-6 py-3 bg-red hover:bg-red/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer text-lg"
>
Discard & Re-upload
</button>
</div>
</div>
</div>
</div>
}
}
templ seriesReviewGameCard(series *db.PlayoffSeries, gr *SeriesGameResult) {
{{
team1Name := seriesTeamName(series.Team1)
team2Name := seriesTeamName(series.Team2)
result := gr.Result
homeWon := result.Winner == "home"
winnerName := team2Name
if homeWon {
winnerName = team1Name
}
hasWarnings := result.TamperingDetected || len(gr.UnmappedPlayers) > 0 || len(gr.FreeAgentWarnings) > 0
}}
<div
class="bg-mantle border border-surface1 rounded-lg overflow-hidden"
x-data="{ expanded: true }"
>
<!-- Game Header (clickable to expand/collapse) -->
<div
class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between
hover:bg-surface1 transition hover:cursor-pointer"
@click="expanded = !expanded"
>
<div class="flex items-center gap-3">
<h3 class="text-md font-bold text-text">Game { fmt.Sprint(gr.GameNumber) }</h3>
if hasWarnings {
<span class="text-yellow text-sm" title="Has warnings">⚠</span>
}
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-subtext0">
{ team1Name }
<span class="font-bold text-text mx-1">{ fmt.Sprint(result.HomeScore) }</span>
-
<span class="font-bold text-text mx-1">{ fmt.Sprint(result.AwayScore) }</span>
{ team2Name }
</span>
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
{ winnerName }
</span>
<!-- Expand/collapse indicator -->
<svg
class="w-4 h-4 text-subtext0 transition-transform"
:class="expanded && 'rotate-180'"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
<!-- Collapsible Content -->
<div x-show="expanded" x-collapse>
<!-- Warnings -->
if hasWarnings {
<div class="p-4 space-y-3 border-b border-surface1">
if result.TamperingDetected && result.TamperingReason != nil {
<div class="bg-red/10 border border-red/30 rounded-lg p-3">
<div class="flex items-center gap-2 mb-1">
<span class="text-red font-bold text-sm">⚠ Inconsistent Data Detected</span>
</div>
<p class="text-red/80 text-sm">{ *result.TamperingReason }</p>
<p class="text-red/60 text-xs mt-1">
This does not block finalization but should be reviewed carefully.
</p>
</div>
}
if len(gr.FreeAgentWarnings) > 0 {
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-3">
<div class="flex items-center gap-2 mb-1">
<span class="text-yellow font-bold text-sm">⚠ Free Agent Issues</span>
</div>
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
for _, fa := range gr.FreeAgentWarnings {
<li>
<span class="text-yellow font-medium">{ fa.Name }</span>
<span class="text-yellow/60"> — { fa.Reason }</span>
</li>
}
</ul>
</div>
}
if len(gr.UnmappedPlayers) > 0 {
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-3">
<div class="flex items-center gap-2 mb-1">
<span class="text-yellow font-bold text-sm">⚠ Unmapped Players</span>
</div>
<p class="text-yellow/80 text-sm mb-1">
Could not be matched to registered players.
</p>
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
for _, p := range gr.UnmappedPlayers {
<li>{ p }</li>
}
</ul>
</div>
}
</div>
}
<!-- Score Display -->
<div class="p-6 border-b border-surface1">
<div class="flex items-center justify-center gap-8 py-2">
<div class="text-center">
<p class="text-sm text-subtext0 mb-1">{ team1Name }</p>
<p class={ "text-4xl font-bold", templ.KV("text-green", homeWon), templ.KV("text-text", !homeWon) }>
{ fmt.Sprint(result.HomeScore) }
</p>
</div>
<div class="text-2xl text-subtext0 font-light">—</div>
<div class="text-center">
<p class="text-sm text-subtext0 mb-1">{ team2Name }</p>
<p class={ "text-4xl font-bold", templ.KV("text-green", !homeWon), templ.KV("text-text", homeWon) }>
{ fmt.Sprint(result.AwayScore) }
</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-center gap-4 mt-2 text-xs text-subtext1">
if result.Arena != "" {
<span>{ result.Arena }</span>
}
if result.EndReason != "" {
<span>{ result.EndReason }</span>
}
<span>
Winner: { winnerName }
</span>
</div>
</div>
<!-- Player Stats Tables -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-0 lg:divide-x divide-surface1">
if series.Team1 != nil {
@seriesReviewTeamStats(series.Team1, result, "home", series.Bracket.Season, series.Bracket.League)
}
if series.Team2 != nil {
@seriesReviewTeamStats(series.Team2, result, "away", series.Bracket.Season, series.Bracket.League)
}
</div>
</div>
</div>
}
templ seriesReviewTeamStats(team *db.Team, result *db.FixtureResult, side string, season *db.Season, league *db.League) {
{{
type playerStat struct {
Username string
PlayerID *int
Stats *db.FixtureResultPlayerStats
}
finalStats := []*playerStat{}
seen := map[string]bool{}
for _, ps := range result.PlayerStats {
if ps.Team == side && ps.PeriodNum == 3 {
if !seen[ps.PlayerGameUserID] {
seen[ps.PlayerGameUserID] = true
finalStats = append(finalStats, &playerStat{
Username: ps.PlayerUsername,
PlayerID: ps.PlayerID,
Stats: ps,
})
}
}
}
}}
<div>
<div class="bg-surface0 border-b border-surface1 px-4 py-2 flex items-center gap-2">
if team.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(team.Color) }
></span>
}
<h4 class="text-sm font-bold text-text">
if side == "home" {
Team 1 —
} else {
Team 2 —
}
@links.TeamNameLinkInSeason(team, season, league)
</h4>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-surface0 border-b border-surface1">
<tr>
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for _, ps := range finalStats {
<tr class="hover:bg-surface0 transition-colors">
<td class="px-3 py-2 text-sm">
<span class="flex items-center gap-1.5">
if ps.PlayerID != nil {
@links.PlayerLinkFromStats(*ps.PlayerID, ps.Username)
} else {
<span class="text-text">{ ps.Username }</span>
<span class="text-yellow text-xs" title="Unmapped player">?</span>
}
if ps.Stats.IsFreeAgent {
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FA
</span>
}
</span>
</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ intPtrStr(ps.Stats.PeriodsPlayed) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Goals) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Assists) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Saves) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Shots) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Blocks) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Passes) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Score) }</td>
</tr>
}
if len(finalStats) == 0 {
<tr>
<td colspan="9" class="px-3 py-4 text-center text-sm text-subtext1">
No player stats recorded
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}

View File

@@ -1,504 +0,0 @@
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"
onclick={ templ.JSUnsafeFuncCall("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"
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("if (!this.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: this.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"
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("var reason = this.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>
}

View File

@@ -1,133 +0,0 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "fmt"
templ SeriesUploadResultPage(series *db.PlayoffSeries) {
{{
backURL := fmt.Sprintf("/series/%d", series.ID)
team1Name := seriesTeamName(series.Team1)
team2Name := seriesTeamName(series.Team2)
boLabel := fmt.Sprintf("BO%d", series.MatchesToWin*2-1)
maxGames := series.MatchesToWin*2 - 1
minGames := series.MatchesToWin
}}
@baseview.Layout(fmt.Sprintf("Upload Series Result — %s vs %s", team1Name, team2Name)) {
<div class="max-w-screen-md 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-6">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-2xl font-bold text-text mb-1">Upload Series Results</h1>
<p class="text-sm text-subtext1">
{ team1Name } vs { team2Name }
<span class="text-subtext0 ml-1">
{ series.Label } · { boLabel }
</span>
</p>
</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"
>
Cancel
</a>
</div>
</div>
</div>
<!-- Upload Form -->
<div
class="bg-mantle border border-surface1 rounded-lg overflow-hidden"
x-data={ fmt.Sprintf("{ gameCount: %d }", minGames) }
>
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
<h2 class="text-lg font-bold text-text">Match Log Files</h2>
</div>
<div class="p-6">
<p class="text-sm text-subtext1 mb-6">
Upload the 3 period match log JSON files for each game in the series.
Select the number of games that were actually played.
</p>
<form
hx-post={ fmt.Sprintf("/series/%d/results/upload", series.ID) }
hx-swap="none"
hx-encoding="multipart/form-data"
class="space-y-6"
>
<!-- Game Count Selector -->
<div>
<label class="block text-sm font-medium text-text mb-2">
Number of Games Played
</label>
<select
name="game_count"
x-model="gameCount"
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"
>
for g := minGames; g <= maxGames; g++ {
<option
value={ fmt.Sprint(g) }
if g == minGames {
selected
}
>
{ fmt.Sprint(g) } games
</option>
}
</select>
<p class="text-xs text-subtext0 mt-1">
First team to { fmt.Sprint(series.MatchesToWin) } wins takes the series
({ fmt.Sprint(minGames) }-{ fmt.Sprint(maxGames) } games possible)
</p>
</div>
<!-- Per-Game File Inputs -->
for g := 1; g <= maxGames; g++ {
<div
x-show={ fmt.Sprintf("gameCount >= %d", g) }
x-cloak
class="border border-surface1 rounded-lg overflow-hidden"
>
<div class="bg-surface0 border-b border-surface1 px-4 py-2">
<h3 class="text-md font-semibold text-text">Game { fmt.Sprint(g) }</h3>
</div>
<div class="p-4 space-y-4">
for p := 1; p <= 3; p++ {
<div>
<label class="block text-sm font-medium text-subtext0 mb-1">
Period { fmt.Sprint(p) }
</label>
<input
type="file"
name={ fmt.Sprintf("game_%d_period_%d", g, p) }
accept=".json"
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
file:mr-4 file:py-1 file:px-3 file:rounded file:border-0
file:text-sm file:font-medium file:bg-blue file:text-mantle
file:hover:bg-blue/80 file:hover:cursor-pointer file:transition
focus:border-blue focus:outline-none"
/>
</div>
}
</div>
</div>
}
<!-- Submit -->
<div class="pt-2">
<button
type="submit"
class="w-full px-4 py-3 bg-blue hover:bg-blue/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer text-lg"
>
Upload & Validate All Games
</button>
</div>
</form>
</div>
</div>
</div>
}
}