Merge pull request 'fixtures' (#2) from fixtures into master

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
h
2026-02-23 20:38:26 +11:00
98 changed files with 9601 additions and 693 deletions

View File

@@ -17,6 +17,7 @@ import (
"git.haelnorr.com/h/oslstats/internal/embedfs" "git.haelnorr.com/h/oslstats/internal/embedfs"
"git.haelnorr.com/h/oslstats/internal/server" "git.haelnorr.com/h/oslstats/internal/server"
"git.haelnorr.com/h/oslstats/internal/store" "git.haelnorr.com/h/oslstats/internal/store"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
) )
// Initializes and runs the server // Initializes and runs the server
@@ -47,8 +48,15 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
return errors.Wrap(err, "discord.NewAPIClient") return errors.Wrap(err, "discord.NewAPIClient")
} }
// Setup Slapshot API
logger.Debug().Msg("Setting up Slapshot API client")
slapAPI, err := slapshotapi.NewSlapAPIClient(cfg.Slapshot)
if err != nil {
return errors.Wrap(err, "slapshotapi.NewSlapAPIClient")
}
logger.Debug().Msg("Setting up HTTP server") logger.Debug().Msg("Setting up HTTP server")
httpServer, err := server.Setup(staticFS, cfg, logger, conn, store, discordAPI) httpServer, err := server.Setup(staticFS, cfg, logger, conn, store, discordAPI, slapAPI)
if err != nil { if err != nil {
return errors.Wrap(err, "setupHttpServer") return errors.Wrap(err, "setupHttpServer")
} }

1
go.mod
View File

@@ -16,6 +16,7 @@ require (
github.com/uptrace/bun v1.2.16 github.com/uptrace/bun v1.2.16
github.com/uptrace/bun/dialect/pgdialect v1.2.16 github.com/uptrace/bun/dialect/pgdialect v1.2.16
github.com/uptrace/bun/driver/pgdriver v1.2.16 github.com/uptrace/bun/driver/pgdriver v1.2.16
golang.org/x/time v0.14.0
) )
require ( require (

2
go.sum
View File

@@ -90,6 +90,8 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -10,6 +10,7 @@ import (
"git.haelnorr.com/h/oslstats/internal/discord" "git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/rbac" "git.haelnorr.com/h/oslstats/internal/rbac"
"git.haelnorr.com/h/oslstats/pkg/oauth" "git.haelnorr.com/h/oslstats/pkg/oauth"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -22,6 +23,7 @@ type Config struct {
Discord *discord.Config Discord *discord.Config
OAuth *oauth.Config OAuth *oauth.Config
RBAC *rbac.Config RBAC *rbac.Config
Slapshot *slapshotapi.Config
Flags *Flags Flags *Flags
} }
@@ -42,6 +44,7 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
discord.NewEZConfIntegration(), discord.NewEZConfIntegration(),
oauth.NewEZConfIntegration(), oauth.NewEZConfIntegration(),
rbac.NewEZConfIntegration(), rbac.NewEZConfIntegration(),
slapshotapi.NewEZConfIntegration(),
) )
if err != nil { if err != nil {
return nil, nil, errors.Wrap(err, "loader.RegisterIntegrations") return nil, nil, errors.Wrap(err, "loader.RegisterIntegrations")
@@ -93,6 +96,11 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
return nil, nil, errors.New("RBAC Config not loaded") return nil, nil, errors.New("RBAC Config not loaded")
} }
slapcfg, ok := loader.GetConfig("slapshotapi")
if !ok {
return nil, nil, errors.New("SlapshotAPI Config not loaded")
}
config := &Config{ config := &Config{
DB: dbcfg.(*db.Config), DB: dbcfg.(*db.Config),
HWS: hwscfg.(*hws.Config), HWS: hwscfg.(*hws.Config),
@@ -101,6 +109,7 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
Discord: discordcfg.(*discord.Config), Discord: discordcfg.(*discord.Config),
OAuth: oauthcfg.(*oauth.Config), OAuth: oauthcfg.(*oauth.Config),
RBAC: rbaccfg.(*rbac.Config), RBAC: rbaccfg.(*rbac.Config),
Slapshot: slapcfg.(*slapshotapi.Config),
Flags: flags, Flags: flags,
} }

View File

@@ -7,15 +7,18 @@ import (
) )
type AuditMeta struct { type AuditMeta struct {
r *http.Request ipAddress string
userAgent string
u *User u *User
} }
func NewAudit(r *http.Request, u *User) *AuditMeta { func NewAudit(ipAdd, agent string, user *User) *AuditMeta {
if u == nil { return &AuditMeta{ipAdd, agent, user}
u = CurrentUser(r.Context()) }
}
return &AuditMeta{r, u} func NewAuditFromRequest(r *http.Request) *AuditMeta {
u := CurrentUser(r.Context())
return &AuditMeta{r.RemoteAddr, r.UserAgent(), u}
} }
// AuditInfo contains metadata for audit logging // AuditInfo contains metadata for audit logging
@@ -45,9 +48,44 @@ func extractTableName[T any]() string {
if bunTag != "" { if bunTag != "" {
// Parse tag: "table:seasons,alias:s" -> "seasons" // Parse tag: "table:seasons,alias:s" -> "seasons"
for part := range strings.SplitSeq(bunTag, ",") { for part := range strings.SplitSeq(bunTag, ",") {
part, _ := strings.CutPrefix(part, "table:") part, match := strings.CutPrefix(part, "table:")
if match {
return part return part
} }
return part
}
}
}
}
// Fallback: use struct name in lowercase + "s"
return strings.ToLower(t.Name()) + "s"
}
// extractTableName gets the bun table alias from a model type using reflection
// Example: Season with `bun:"table:seasons,alias:s"` returns "s"
func extractTableAlias[T any]() string {
var model T
t := reflect.TypeOf(model)
// Handle pointer types
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
// Look for bun.BaseModel field with table tag
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.Type.Name() == "BaseModel" {
bunTag := field.Tag.Get("bun")
if bunTag != "" {
// Parse tag: "table:seasons,alias:s" -> "seasons"
for part := range strings.SplitSeq(bunTag, ",") {
part, match := strings.CutPrefix(part, "alias:")
if match {
return part
}
}
} }
} }
} }

View File

@@ -3,7 +3,6 @@ package db
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@@ -78,7 +77,6 @@ func (a *AuditLogFilter) UserIDs(ids []int) *AuditLogFilter {
} }
func (a *AuditLogFilter) Actions(actions []string) *AuditLogFilter { func (a *AuditLogFilter) Actions(actions []string) *AuditLogFilter {
fmt.Println(actions)
if len(actions) > 0 { if len(actions) > 0 {
a.In("al.action", actions) a.In("al.action", actions)
} }

View File

@@ -49,9 +49,6 @@ func log(
if meta.u == nil { if meta.u == nil {
return errors.New("user cannot be nil for audit logging") return errors.New("user cannot be nil for audit logging")
} }
if meta.r == nil {
return errors.New("request cannot be nil for audit logging")
}
// Convert resourceID to string // Convert resourceID to string
var resourceIDStr *string var resourceIDStr *string
@@ -70,18 +67,14 @@ func log(
detailsJSON = jsonBytes detailsJSON = jsonBytes
} }
// Extract IP and User-Agent from request
ipAddress := meta.r.RemoteAddr
userAgent := meta.r.UserAgent()
log := &AuditLog{ log := &AuditLog{
UserID: meta.u.ID, UserID: meta.u.ID,
Action: info.Action, Action: info.Action,
ResourceType: info.ResourceType, ResourceType: info.ResourceType,
ResourceID: resourceIDStr, ResourceID: resourceIDStr,
Details: detailsJSON, Details: detailsJSON,
IPAddress: ipAddress, IPAddress: meta.ipAddress,
UserAgent: userAgent, UserAgent: meta.userAgent,
Result: result, Result: result,
ErrorMessage: errorMessage, ErrorMessage: errorMessage,
CreatedAt: time.Now().Unix(), CreatedAt: time.Now().Unix(),

View File

@@ -28,12 +28,41 @@ type Fixture struct {
League *League `bun:"rel:belongs-to,join:league_id=id"` League *League `bun:"rel:belongs-to,join:league_id=id"`
HomeTeam *Team `bun:"rel:belongs-to,join:home_team_id=id"` HomeTeam *Team `bun:"rel:belongs-to,join:home_team_id=id"`
AwayTeam *Team `bun:"rel:belongs-to,join:away_team_id=id"` AwayTeam *Team `bun:"rel:belongs-to,join:away_team_id=id"`
Schedules []*FixtureSchedule `bun:"rel:has-many,join:id=fixture_id"`
}
// CanSchedule checks if the user is a manager of one of the teams in the fixture.
// Returns (canSchedule, teamID) where teamID is the team the user manages (0 if not a manager).
func (f *Fixture) CanSchedule(ctx context.Context, tx bun.Tx, user *User) (bool, int, error) {
if user == nil || user.Player == nil {
return false, 0, nil
}
roster := new(TeamRoster)
err := tx.NewSelect().
Model(roster).
Column("team_id", "is_manager").
Where("team_id IN (?)", bun.In([]int{f.HomeTeamID, f.AwayTeamID})).
Where("season_id = ?", f.SeasonID).
Where("league_id = ?", f.LeagueID).
Where("player_id = ?", user.Player.ID).
Scan(ctx)
if err != nil {
if err.Error() == "sql: no rows in result set" {
return false, 0, nil
}
return false, 0, errors.Wrap(err, "tx.NewSelect")
}
if !roster.IsManager {
return false, 0, nil
}
return true, roster.TeamID, nil
} }
func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
homeTeamID, awayTeamID, round int, audit *AuditMeta, homeTeamID, awayTeamID, round int, audit *AuditMeta,
) (*Fixture, error) { ) (*Fixture, error) {
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "GetSeasonLeague") return nil, errors.Wrap(err, "GetSeasonLeague")
} }
@@ -59,7 +88,7 @@ func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName
func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
round int, audit *AuditMeta, round int, audit *AuditMeta,
) ([]*Fixture, error) { ) ([]*Fixture, error) {
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "GetSeasonLeague") return nil, errors.Wrap(err, "GetSeasonLeague")
} }
@@ -71,22 +100,22 @@ func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName s
return fixtures, nil return fixtures, nil
} }
func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Fixture, error) { func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*SeasonLeague, []*Fixture, error) {
season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeasonLeague") return nil, nil, errors.Wrap(err, "GetSeasonLeague")
} }
fixtures, err := GetList[Fixture](tx). fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", season.ID). Where("season_id = ?", sl.SeasonID).
Where("league_id = ?", league.ID). Where("league_id = ?", sl.LeagueID).
Order("game_week ASC NULLS FIRST", "round ASC", "id ASC"). Order("game_week ASC NULLS FIRST", "round ASC", "id ASC").
Relation("HomeTeam"). Relation("HomeTeam").
Relation("AwayTeam"). Relation("AwayTeam").
GetAll(ctx) GetAll(ctx)
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetList") return nil, nil, errors.Wrap(err, "GetList")
} }
return season, league, fixtures, nil return sl, fixtures, nil
} }
func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) { func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) {
@@ -98,6 +127,38 @@ func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) {
Get(ctx) Get(ctx)
} }
// GetAllocatedFixtures returns all fixtures with a game_week assigned for a season+league.
func GetAllocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Fixture, error) {
fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("game_week IS NOT NULL").
Order("game_week ASC", "round ASC", "id ASC").
Relation("HomeTeam").
Relation("AwayTeam").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
return fixtures, nil
}
func GetFixturesForTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID int) ([]*Fixture, error) {
fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("game_week IS NOT NULL").
Where("(home_team_id = ? OR away_team_id = ?)", teamID, teamID).
Order("game_week ASC", "round ASC", "id ASC").
Relation("HomeTeam").
Relation("AwayTeam").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
return fixtures, nil
}
func GetFixturesByGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID, gameweek int) ([]*Fixture, error) { func GetFixturesByGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID, gameweek int) ([]*Fixture, error) {
fixtures, err := GetList[Fixture](tx). fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", seasonID). Where("season_id = ?", seasonID).
@@ -180,13 +241,13 @@ func UpdateFixtureGameWeeks(ctx context.Context, tx bun.Tx, fixtures []*Fixture,
} }
func DeleteAllFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, audit *AuditMeta) error { func DeleteAllFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, audit *AuditMeta) error {
season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil { if err != nil {
return errors.Wrap(err, "GetSeasonLeague") return errors.Wrap(err, "GetSeasonLeague")
} }
err = DeleteItem[Fixture](tx). err = DeleteItem[Fixture](tx).
Where("season_id = ?", season.ID). Where("season_id = ?", sl.SeasonID).
Where("league_id = ?", league.ID). Where("league_id = ?", sl.LeagueID).
WithAudit(audit, nil). WithAudit(audit, nil).
Delete(ctx) Delete(ctx)
if err != nil { if err != nil {
@@ -269,7 +330,7 @@ func playOtherTeams(team *Team, teams []*Team, round int) []*versus {
matchups := make([]*versus, len(teams)) matchups := make([]*versus, len(teams))
for i, opponent := range teams { for i, opponent := range teams {
versus := &versus{} versus := &versus{}
if i%2+round%2 == 0 { if (i+round)%2 == 0 {
versus.homeTeam = team versus.homeTeam = team
versus.awayTeam = opponent versus.awayTeam = opponent
} else { } else {
@@ -280,3 +341,40 @@ func playOtherTeams(team *Team, teams []*Team, round int) []*versus {
} }
return matchups return matchups
} }
func AutoAllocateFixtures(fixtures []*Fixture, gamesPerWeek, startingWeek int) []*Fixture {
gameWeek := startingWeek
teamPlays := map[int]int{}
// Work on a copy so we can track what's remaining
remaining := make([]*Fixture, len(fixtures))
copy(remaining, fixtures)
for len(remaining) > 0 {
madeProgress := false
nextRemaining := make([]*Fixture, 0, len(remaining))
for _, fixture := range remaining {
if teamPlays[fixture.HomeTeamID] < gamesPerWeek &&
teamPlays[fixture.AwayTeamID] < gamesPerWeek {
gw := gameWeek
fixture.GameWeek = &gw
teamPlays[fixture.HomeTeamID]++
teamPlays[fixture.AwayTeamID]++
madeProgress = true
} else {
nextRemaining = append(nextRemaining, fixture)
}
}
if !madeProgress {
// No fixture could be placed this week — advance to avoid infinite loop
// (shouldn't happen with valid fixture data, but guards against edge cases)
gameWeek++
teamPlays = map[int]int{}
continue
}
remaining = nextRemaining
if len(remaining) > 0 {
gameWeek++
teamPlays = map[int]int{}
}
}
return fixtures
}

View File

@@ -0,0 +1,587 @@
package db
import (
"context"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type FixtureResult struct {
bun.BaseModel `bun:"table:fixture_results,alias:fr"`
ID int `bun:"id,pk,autoincrement"`
FixtureID int `bun:",notnull,unique"`
Winner string `bun:",notnull"`
HomeScore int `bun:",notnull"`
AwayScore int `bun:",notnull"`
MatchType string
Arena string
EndReason string
PeriodsEnabled bool
CustomMercyRule int
MatchLength int
CreatedAt int64 `bun:",notnull"`
UpdatedAt *int64
UploadedByUserID int `bun:",notnull"`
Finalized bool `bun:",default:false"`
TamperingDetected bool `bun:",default:false"`
TamperingReason *string
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
UploadedBy *User `bun:"rel:belongs-to,join:uploaded_by_user_id=id"`
PlayerStats []*FixtureResultPlayerStats `bun:"rel:has-many,join:id=fixture_result_id"`
}
type FixtureResultPlayerStats struct {
bun.BaseModel `bun:"table:fixture_result_player_stats,alias:frps"`
ID int `bun:"id,pk,autoincrement"`
FixtureResultID int `bun:",notnull"`
PeriodNum int `bun:",notnull"`
PlayerID *int // NULL for unmapped/free agents
PlayerGameUserID string `bun:",notnull"`
PlayerUsername string `bun:",notnull"`
TeamID *int // NULL for unmapped
Team string `bun:",notnull"` // 'home' or 'away'
// All stats as INT (nullable)
Goals *int
Assists *int
PrimaryAssists *int
SecondaryAssists *int
Saves *int
Blocks *int
Shots *int
Turnovers *int
Takeaways *int
Passes *int
PossessionTimeSec *int
FaceoffsWon *int
FaceoffsLost *int
PostHits *int
OvertimeGoals *int
GameWinningGoals *int
Score *int
ContributedGoals *int
ConcededGoals *int
GamesPlayed *int
Wins *int
Losses *int
OvertimeWins *int
OvertimeLosses *int
Ties *int
Shutouts *int
ShutoutsAgainst *int
HasMercyRuled *int
WasMercyRuled *int
PeriodsPlayed *int
IsFreeAgent bool `bun:"is_free_agent,default:false"`
FixtureResult *FixtureResult `bun:"rel:belongs-to,join:fixture_result_id=id"`
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
TeamRel *Team `bun:"rel:belongs-to,join:team_id=id"`
}
// PlayerWithPlayStatus is a helper struct for overview display
type PlayerWithPlayStatus struct {
Player *Player
Played bool
IsManager bool
IsFreeAgent bool
Stats *FixtureResultPlayerStats // Period 3 (final cumulative) stats, nil if no result
}
// InsertFixtureResult stores a new match result with all player stats in a single transaction.
func InsertFixtureResult(
ctx context.Context,
tx bun.Tx,
result *FixtureResult,
playerStats []*FixtureResultPlayerStats,
audit *AuditMeta,
) (*FixtureResult, error) {
if result == nil {
return nil, errors.New("result cannot be nil")
}
result.CreatedAt = time.Now().Unix()
err := Insert(tx, result).WithAudit(audit, &AuditInfo{
Action: "fixture_results.create",
ResourceType: "fixture_result",
ResourceID: nil,
Details: map[string]any{
"fixture_id": result.FixtureID,
"winner": result.Winner,
"home_score": result.HomeScore,
"away_score": result.AwayScore,
"tampering_detected": result.TamperingDetected,
},
}).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert result")
}
// Set the fixture_result_id on all player stats
for _, ps := range playerStats {
ps.FixtureResultID = result.ID
}
// Insert player stats in bulk
if len(playerStats) > 0 {
err = InsertMultiple(tx, playerStats).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "InsertMultiple player stats")
}
}
return result, nil
}
// GetFixtureResult retrieves a result with all player stats for a fixture.
// Returns nil, nil if no result exists.
func GetFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) {
result := new(FixtureResult)
err := tx.NewSelect().
Model(result).
Where("fr.fixture_id = ?", fixtureID).
Relation("Fixture").
Relation("UploadedBy").
Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC")
}).
Relation("PlayerStats.Player").
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 result, nil
}
// GetPendingFixtureResult retrieves a non-finalized result for review/edit.
// Returns nil, nil if no pending result exists.
func GetPendingFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) {
result := new(FixtureResult)
err := tx.NewSelect().
Model(result).
Where("fr.fixture_id = ?", fixtureID).
Where("fr.finalized = false").
Relation("Fixture").
Relation("UploadedBy").
Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC")
}).
Relation("PlayerStats.Player").
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 result, nil
}
// FinalizeFixtureResult marks a pending result as finalized.
func FinalizeFixtureResult(
ctx context.Context,
tx bun.Tx,
fixtureID int,
audit *AuditMeta,
) error {
result, err := GetPendingFixtureResult(ctx, tx, fixtureID)
if err != nil {
return errors.Wrap(err, "GetPendingFixtureResult")
}
if result == nil {
return BadRequest("no pending result to finalize")
}
now := time.Now().Unix()
result.Finalized = true
result.UpdatedAt = &now
err = UpdateByID(tx, result.ID, result).
Column("finalized", "updated_at").
WithAudit(audit, &AuditInfo{
Action: "fixture_results.finalize",
ResourceType: "fixture_result",
ResourceID: result.ID,
Details: map[string]any{
"fixture_id": fixtureID,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}
// DeleteFixtureResult removes a pending result and all associated player stats (CASCADE).
func DeleteFixtureResult(
ctx context.Context,
tx bun.Tx,
fixtureID int,
audit *AuditMeta,
) error {
result, err := GetPendingFixtureResult(ctx, tx, fixtureID)
if err != nil {
return errors.Wrap(err, "GetPendingFixtureResult")
}
if result == nil {
return BadRequest("no pending result to discard")
}
err = DeleteByID[FixtureResult](tx, result.ID).
WithAudit(audit, &AuditInfo{
Action: "fixture_results.discard",
ResourceType: "fixture_result",
ResourceID: result.ID,
Details: map[string]any{
"fixture_id": fixtureID,
},
}).Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteByID")
}
return nil
}
// GetFinalizedResultsForFixtures returns finalized results for a list of fixture IDs.
// Returns a map of fixtureID -> *FixtureResult (without player stats for efficiency).
func GetFinalizedResultsForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs []int) (map[int]*FixtureResult, error) {
if len(fixtureIDs) == 0 {
return map[int]*FixtureResult{}, nil
}
results, err := GetList[FixtureResult](tx).
Where("fixture_id IN (?)", bun.In(fixtureIDs)).
Where("finalized = true").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
resultMap := make(map[int]*FixtureResult, len(results))
for _, r := range results {
resultMap[r.FixtureID] = r
}
return resultMap, nil
}
// AggregatedPlayerStats holds summed stats for a player across multiple fixtures.
type AggregatedPlayerStats struct {
PlayerID int `bun:"player_id"`
PlayerName string `bun:"player_name"`
GamesPlayed int `bun:"games_played"`
Score int `bun:"total_score"`
Goals int `bun:"total_goals"`
Assists int `bun:"total_assists"`
Saves int `bun:"total_saves"`
Shots int `bun:"total_shots"`
Blocks int `bun:"total_blocks"`
Passes int `bun:"total_passes"`
}
// GetAggregatedPlayerStatsForTeam returns aggregated period-3 stats for all mapped
// players on a given team across all finalized fixture results.
func GetAggregatedPlayerStatsForTeam(
ctx context.Context,
tx bun.Tx,
teamID int,
fixtureIDs []int,
) ([]*AggregatedPlayerStats, error) {
if len(fixtureIDs) == 0 {
return nil, nil
}
var stats []*AggregatedPlayerStats
err := tx.NewRaw(`
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.score), 0) AS total_score,
COALESCE(SUM(frps.goals), 0) AS total_goals,
COALESCE(SUM(frps.assists), 0) AS total_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
FROM fixture_result_player_stats frps
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND fr.fixture_id IN (?)
AND frps.team_id = ?
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_score DESC
`, bun.In(fixtureIDs), teamID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// TeamRecord holds win/loss/draw record and goal totals for a team.
type TeamRecord struct {
Played int
Wins int
OvertimeWins int
OvertimeLosses int
Losses int
Draws int
GoalsFor int
GoalsAgainst int
Points int
}
// Point values for the leaderboard scoring system.
const (
PointsWin = 3
PointsOvertimeWin = 2
PointsOvertimeLoss = 1
PointsLoss = 0
)
// ComputeTeamRecord calculates W-OTW-OTL-L, GF/GA, and points from fixtures and results.
// Points: Win=3, OT Win=2, OT Loss=1, Loss=0.
func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*FixtureResult) *TeamRecord {
rec := &TeamRecord{}
for _, f := range fixtures {
res, ok := resultMap[f.ID]
if !ok {
continue
}
rec.Played++
isHome := f.HomeTeamID == teamID
if isHome {
rec.GoalsFor += res.HomeScore
rec.GoalsAgainst += res.AwayScore
} else {
rec.GoalsFor += res.AwayScore
rec.GoalsAgainst += res.HomeScore
}
won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away")
lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home")
isOT := strings.EqualFold(res.EndReason, "Overtime")
switch {
case won && isOT:
rec.OvertimeWins++
rec.Points += PointsOvertimeWin
case won:
rec.Wins++
rec.Points += PointsWin
case lost && isOT:
rec.OvertimeLosses++
rec.Points += PointsOvertimeLoss
case lost:
rec.Losses++
rec.Points += PointsLoss
default:
rec.Draws++
}
}
return rec
}
// LeaderboardEntry represents a single team's standing in the league table.
type LeaderboardEntry struct {
Position int
Team *Team
Record *TeamRecord
}
// ComputeLeaderboard builds a sorted leaderboard from teams, fixtures, and results.
// Teams are sorted by: Points DESC, Goal Differential DESC, Goals For DESC, Name ASC.
func ComputeLeaderboard(teams []*Team, fixtures []*Fixture, resultMap map[int]*FixtureResult) []*LeaderboardEntry {
entries := make([]*LeaderboardEntry, 0, len(teams))
// Build a map of team ID -> fixtures involving that team
teamFixtures := make(map[int][]*Fixture)
for _, f := range fixtures {
teamFixtures[f.HomeTeamID] = append(teamFixtures[f.HomeTeamID], f)
teamFixtures[f.AwayTeamID] = append(teamFixtures[f.AwayTeamID], f)
}
for _, team := range teams {
record := ComputeTeamRecord(team.ID, teamFixtures[team.ID], resultMap)
entries = append(entries, &LeaderboardEntry{
Team: team,
Record: record,
})
}
// Sort: Points DESC, then goal diff DESC, then GF DESC, then name ASC
sort.Slice(entries, func(i, j int) bool {
ri, rj := entries[i].Record, entries[j].Record
if ri.Points != rj.Points {
return ri.Points > rj.Points
}
diffI := ri.GoalsFor - ri.GoalsAgainst
diffJ := rj.GoalsFor - rj.GoalsAgainst
if diffI != diffJ {
return diffI > diffJ
}
if ri.GoalsFor != rj.GoalsFor {
return ri.GoalsFor > rj.GoalsFor
}
return entries[i].Team.Name < entries[j].Team.Name
})
// Assign positions
for i := range entries {
entries[i].Position = i + 1
}
return entries
}
// GetFixtureTeamRosters returns all team players with participation status for a fixture.
// Returns: map["home"|"away"] -> []*PlayerWithPlayStatus
func GetFixtureTeamRosters(
ctx context.Context,
tx bun.Tx,
fixture *Fixture,
result *FixtureResult,
) (map[string][]*PlayerWithPlayStatus, error) {
if fixture == nil {
return nil, errors.New("fixture cannot be nil")
}
rosters := map[string][]*PlayerWithPlayStatus{}
// Get home team roster
homeRosters := []*TeamRoster{}
err := tx.NewSelect().
Model(&homeRosters).
Where("tr.team_id = ?", fixture.HomeTeamID).
Where("tr.season_id = ?", fixture.SeasonID).
Where("tr.league_id = ?", fixture.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 home roster")
}
// Get away team roster
awayRosters := []*TeamRoster{}
err = tx.NewSelect().
Model(&awayRosters).
Where("tr.team_id = ?", fixture.AwayTeamID).
Where("tr.season_id = ?", fixture.SeasonID).
Where("tr.league_id = ?", fixture.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 away roster")
}
// Build maps of player IDs that played and their period 3 stats
playedPlayerIDs := map[int]bool{}
playerStatsByID := map[int]*FixtureResultPlayerStats{}
freeAgentPlayerIDs := map[int]bool{}
// Track free agents by team side for roster inclusion
freeAgentsByTeam := map[string]map[int]*FixtureResultPlayerStats{} // "home"/"away" -> playerID -> stats
freeAgentsByTeam["home"] = map[int]*FixtureResultPlayerStats{}
freeAgentsByTeam["away"] = map[int]*FixtureResultPlayerStats{}
if result != nil {
for _, ps := range result.PlayerStats {
if ps.PlayerID != nil {
playedPlayerIDs[*ps.PlayerID] = true
if ps.PeriodNum == 3 {
playerStatsByID[*ps.PlayerID] = ps
}
if ps.IsFreeAgent {
freeAgentPlayerIDs[*ps.PlayerID] = true
if ps.PeriodNum == 3 {
freeAgentsByTeam[ps.Team][*ps.PlayerID] = ps
}
}
}
}
}
// Build a set of roster player IDs so we can skip them when adding free agents
rosterPlayerIDs := map[int]bool{}
for _, r := range homeRosters {
if r.Player != nil {
rosterPlayerIDs[r.Player.ID] = true
}
}
for _, r := range awayRosters {
if r.Player != nil {
rosterPlayerIDs[r.Player.ID] = true
}
}
// Build home roster with play status and stats
for _, r := range homeRosters {
played := false
var stats *FixtureResultPlayerStats
if result != nil && r.Player != nil {
played = playedPlayerIDs[r.Player.ID]
stats = playerStatsByID[r.Player.ID]
}
rosters["home"] = append(rosters["home"], &PlayerWithPlayStatus{
Player: r.Player,
Played: played,
IsManager: r.IsManager,
Stats: stats,
})
}
// Build away roster with play status and stats
for _, r := range awayRosters {
played := false
var stats *FixtureResultPlayerStats
if result != nil && r.Player != nil {
played = playedPlayerIDs[r.Player.ID]
stats = playerStatsByID[r.Player.ID]
}
rosters["away"] = append(rosters["away"], &PlayerWithPlayStatus{
Player: r.Player,
Played: played,
IsManager: r.IsManager,
Stats: stats,
})
}
// Add free agents who played but are not on the team roster
for team, faStats := range freeAgentsByTeam {
for playerID, stats := range faStats {
if rosterPlayerIDs[playerID] {
continue // Already on the roster, skip
}
if stats.Player == nil {
// Try to load the player
player, err := GetPlayer(ctx, tx, playerID)
if err != nil {
continue // Skip if we can't load
}
stats.Player = player
}
rosters[team] = append(rosters[team], &PlayerWithPlayStatus{
Player: stats.Player,
Played: true,
IsManager: false,
IsFreeAgent: true,
Stats: stats,
})
}
}
return rosters, nil
}

View File

@@ -0,0 +1,426 @@
package db
import (
"context"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// ScheduleStatus represents the current status of a fixture schedule proposal
type ScheduleStatus string
const (
ScheduleStatusPending ScheduleStatus = "pending"
ScheduleStatusAccepted ScheduleStatus = "accepted"
ScheduleStatusRejected ScheduleStatus = "rejected"
ScheduleStatusRescheduled ScheduleStatus = "rescheduled"
ScheduleStatusPostponed ScheduleStatus = "postponed"
ScheduleStatusCancelled ScheduleStatus = "cancelled"
ScheduleStatusWithdrawn ScheduleStatus = "withdrawn"
)
// IsTerminal returns true if the status is a terminal (immutable) state
func (s ScheduleStatus) IsTerminal() bool {
switch s {
case ScheduleStatusRejected, ScheduleStatusRescheduled,
ScheduleStatusPostponed, ScheduleStatusCancelled,
ScheduleStatusWithdrawn:
return true
}
return false
}
// RescheduleReason represents the predefined reasons for rescheduling or postponing
type RescheduleReason string
const (
ReasonMutuallyAgreed RescheduleReason = "Mutually Agreed"
ReasonTeamUnavailable RescheduleReason = "Team Unavailable"
ReasonTeamNoShow RescheduleReason = "Team No-show"
)
type FixtureSchedule struct {
bun.BaseModel `bun:"table:fixture_schedules,alias:fs"`
ID int `bun:"id,pk,autoincrement"`
FixtureID int `bun:",notnull"`
ScheduledTime *time.Time `bun:"scheduled_time"`
ProposedByTeamID int `bun:",notnull"`
AcceptedByTeamID *int `bun:"accepted_by_team_id"`
Status ScheduleStatus `bun:",notnull,default:'pending'"`
RescheduleReason *string `bun:"reschedule_reason"`
CreatedAt int64 `bun:",notnull"`
UpdatedAt *int64 `bun:"updated_at"`
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
ProposedBy *Team `bun:"rel:belongs-to,join:proposed_by_team_id=id"`
AcceptedBy *Team `bun:"rel:belongs-to,join:accepted_by_team_id=id"`
}
// GetAcceptedSchedulesForFixtures returns the accepted schedule for each fixture in the given list.
// Returns a map of fixtureID -> *FixtureSchedule (only accepted schedules are included).
func GetAcceptedSchedulesForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs []int) (map[int]*FixtureSchedule, error) {
if len(fixtureIDs) == 0 {
return map[int]*FixtureSchedule{}, nil
}
schedules, err := GetList[FixtureSchedule](tx).
Where("fixture_id IN (?)", bun.In(fixtureIDs)).
Where("status = ?", ScheduleStatusAccepted).
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
result := make(map[int]*FixtureSchedule, len(schedules))
for _, s := range schedules {
// If multiple accepted exist (shouldn't happen), keep the most recent
existing, ok := result[s.FixtureID]
if !ok || s.CreatedAt > existing.CreatedAt {
result[s.FixtureID] = s
}
}
return result, nil
}
// GetFixtureScheduleHistory returns all schedule records for a fixture in chronological order
func GetFixtureScheduleHistory(ctx context.Context, tx bun.Tx, fixtureID int) ([]*FixtureSchedule, error) {
schedules, err := GetList[FixtureSchedule](tx).
Where("fixture_id = ?", fixtureID).
Order("created_at ASC", "id ASC").
Relation("ProposedBy").
Relation("AcceptedBy").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
return schedules, nil
}
// GetCurrentFixtureSchedule returns the most recent schedule record for a fixture.
// Returns nil, nil if no schedule exists.
func GetCurrentFixtureSchedule(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureSchedule, error) {
schedule := new(FixtureSchedule)
err := tx.NewSelect().
Model(schedule).
Where("fixture_id = ?", fixtureID).
Order("created_at DESC", "id DESC").
Relation("ProposedBy").
Relation("AcceptedBy").
Limit(1).
Scan(ctx)
if err != nil {
if err.Error() == "sql: no rows in result set" {
return nil, nil
}
return nil, errors.Wrap(err, "tx.NewSelect")
}
return schedule, nil
}
// ProposeFixtureSchedule creates a new pending schedule proposal for a fixture.
// If there is an existing pending record with no time (postponed placeholder), it will be
// superseded. Cannot propose on cancelled or accepted schedules.
func ProposeFixtureSchedule(
ctx context.Context,
tx bun.Tx,
fixtureID, proposedByTeamID int,
scheduledTime time.Time,
audit *AuditMeta,
) (*FixtureSchedule, error) {
current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID)
if err != nil {
return nil, errors.Wrap(err, "GetCurrentFixtureSchedule")
}
if current != nil {
switch current.Status {
case ScheduleStatusCancelled:
return nil, BadRequest("cannot propose a new time for a cancelled fixture")
case ScheduleStatusAccepted:
return nil, BadRequest("fixture already has an accepted schedule; use reschedule instead")
case ScheduleStatusPending:
// Supersede existing pending record (e.g., postponed placeholder or old proposal)
now := time.Now().Unix()
current.Status = ScheduleStatusRescheduled
current.UpdatedAt = &now
err = UpdateByID(tx, current.ID, current).
Column("status", "updated_at").
Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "UpdateByID")
}
// rejected, rescheduled, postponed are terminal — safe to create a new proposal
}
}
schedule := &FixtureSchedule{
FixtureID: fixtureID,
ScheduledTime: &scheduledTime,
ProposedByTeamID: proposedByTeamID,
Status: ScheduleStatusPending,
CreatedAt: time.Now().Unix(),
}
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.propose",
ResourceType: "fixture_schedule",
ResourceID: fixtureID,
Details: map[string]any{
"fixture_id": fixtureID,
"proposed_by": proposedByTeamID,
"scheduled_time": scheduledTime,
},
}).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert")
}
return schedule, nil
}
// AcceptFixtureSchedule accepts a pending schedule proposal.
// The acceptedByTeamID must be the other team (not the proposer).
func AcceptFixtureSchedule(
ctx context.Context,
tx bun.Tx,
scheduleID, acceptedByTeamID int,
audit *AuditMeta,
) error {
schedule, err := GetByID[FixtureSchedule](tx, scheduleID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetByID")
}
if schedule.Status != ScheduleStatusPending {
return BadRequest("schedule is not in pending status")
}
if schedule.ProposedByTeamID == acceptedByTeamID {
return BadRequest("cannot accept your own proposal")
}
now := time.Now().Unix()
schedule.AcceptedByTeamID = &acceptedByTeamID
schedule.Status = ScheduleStatusAccepted
schedule.UpdatedAt = &now
err = UpdateByID(tx, schedule.ID, schedule).
Column("accepted_by_team_id", "status", "updated_at").
WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.accept",
ResourceType: "fixture_schedule",
ResourceID: scheduleID,
Details: map[string]any{
"fixture_id": schedule.FixtureID,
"accepted_by": acceptedByTeamID,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}
// RejectFixtureSchedule rejects a pending schedule proposal.
func RejectFixtureSchedule(
ctx context.Context,
tx bun.Tx,
scheduleID int,
audit *AuditMeta,
) error {
schedule, err := GetByID[FixtureSchedule](tx, scheduleID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetByID")
}
if schedule.Status != ScheduleStatusPending {
return BadRequest("schedule is not in pending status")
}
now := time.Now().Unix()
schedule.Status = ScheduleStatusRejected
schedule.UpdatedAt = &now
err = UpdateByID(tx, schedule.ID, schedule).
Column("status", "updated_at").
WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.reject",
ResourceType: "fixture_schedule",
ResourceID: scheduleID,
Details: map[string]any{
"fixture_id": schedule.FixtureID,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}
// RescheduleFixtureSchedule marks the current accepted schedule as rescheduled and creates
// a new pending proposal with the new time.
func RescheduleFixtureSchedule(
ctx context.Context,
tx bun.Tx,
fixtureID, proposedByTeamID int,
newTime time.Time,
reason string,
audit *AuditMeta,
) (*FixtureSchedule, error) {
current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID)
if err != nil {
return nil, errors.Wrap(err, "GetCurrentFixtureSchedule")
}
if current == nil || current.Status != ScheduleStatusAccepted {
return nil, BadRequest("no accepted schedule to reschedule")
}
now := time.Now().Unix()
current.Status = ScheduleStatusRescheduled
current.RescheduleReason = &reason
current.UpdatedAt = &now
err = UpdateByID(tx, current.ID, current).
Column("status", "reschedule_reason", "updated_at").
Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "UpdateByID")
}
// Create new pending proposal
schedule := &FixtureSchedule{
FixtureID: fixtureID,
ScheduledTime: &newTime,
ProposedByTeamID: proposedByTeamID,
Status: ScheduleStatusPending,
CreatedAt: time.Now().Unix(),
}
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.reschedule",
ResourceType: "fixture_schedule",
ResourceID: fixtureID,
Details: map[string]any{
"fixture_id": fixtureID,
"proposed_by": proposedByTeamID,
"new_time": newTime,
"reason": reason,
"old_schedule_id": current.ID,
},
}).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert")
}
return schedule, nil
}
// PostponeFixtureSchedule marks the current accepted schedule as postponed.
// This is a terminal state — a new proposal can be created afterwards.
func PostponeFixtureSchedule(
ctx context.Context,
tx bun.Tx,
fixtureID int,
reason string,
audit *AuditMeta,
) error {
current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID)
if err != nil {
return errors.Wrap(err, "GetCurrentFixtureSchedule")
}
if current == nil || current.Status != ScheduleStatusAccepted {
return BadRequest("no accepted schedule to postpone")
}
now := time.Now().Unix()
current.Status = ScheduleStatusPostponed
current.RescheduleReason = &reason
current.UpdatedAt = &now
err = UpdateByID(tx, current.ID, current).
Column("status", "reschedule_reason", "updated_at").
WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.postpone",
ResourceType: "fixture_schedule",
ResourceID: fixtureID,
Details: map[string]any{
"fixture_id": fixtureID,
"reason": reason,
"old_schedule_id": current.ID,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}
// WithdrawFixtureSchedule allows the proposer to withdraw their pending proposal.
// Only the team that proposed can withdraw it.
func WithdrawFixtureSchedule(
ctx context.Context,
tx bun.Tx,
scheduleID, withdrawByTeamID int,
audit *AuditMeta,
) error {
schedule, err := GetByID[FixtureSchedule](tx, scheduleID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetByID")
}
if schedule.Status != ScheduleStatusPending {
return BadRequest("schedule is not in pending status")
}
if schedule.ProposedByTeamID != withdrawByTeamID {
return BadRequest("only the proposing team can withdraw their proposal")
}
now := time.Now().Unix()
schedule.Status = ScheduleStatusWithdrawn
schedule.UpdatedAt = &now
err = UpdateByID(tx, schedule.ID, schedule).
Column("status", "updated_at").
WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.withdraw",
ResourceType: "fixture_schedule",
ResourceID: scheduleID,
Details: map[string]any{
"fixture_id": schedule.FixtureID,
"withdrawn_by": withdrawByTeamID,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}
// CancelFixtureSchedule marks the current schedule as cancelled. This is a terminal state.
// Requires fixtures.manage permission (moderator-level).
func CancelFixtureSchedule(
ctx context.Context,
tx bun.Tx,
fixtureID int,
reason string,
audit *AuditMeta,
) error {
current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID)
if err != nil {
return errors.Wrap(err, "GetCurrentFixtureSchedule")
}
if current == nil {
return BadRequest("no schedule to cancel")
}
if current.Status.IsTerminal() {
return BadRequest("schedule is already in a terminal state")
}
now := time.Now().Unix()
current.Status = ScheduleStatusCancelled
current.RescheduleReason = &reason
current.UpdatedAt = &now
err = UpdateByID(tx, current.ID, current).
Column("status", "reschedule_reason", "updated_at").
WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.cancel",
ResourceType: "fixture_schedule",
ResourceID: fixtureID,
Details: map[string]any{
"fixture_id": fixtureID,
"reason": reason,
"schedule_id": current.ID,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}

342
internal/db/freeagent.go Normal file
View File

@@ -0,0 +1,342 @@
package db
import (
"context"
"fmt"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// SeasonLeagueFreeAgent tracks players registered as free agents in a season_league.
type SeasonLeagueFreeAgent struct {
bun.BaseModel `bun:"table:season_league_free_agents,alias:slfa"`
SeasonID int `bun:",pk,notnull"`
LeagueID int `bun:",pk,notnull"`
PlayerID int `bun:",pk,notnull"`
RegisteredAt int64 `bun:",notnull"`
RegisteredByUserID int `bun:",notnull"`
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
League *League `bun:"rel:belongs-to,join:league_id=id"`
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
RegisteredBy *User `bun:"rel:belongs-to,join:registered_by_user_id=id"`
}
// FixtureFreeAgent tracks which free agents are nominated for specific fixtures.
type FixtureFreeAgent struct {
bun.BaseModel `bun:"table:fixture_free_agents,alias:ffa"`
FixtureID int `bun:",pk,notnull"`
PlayerID int `bun:",pk,notnull"`
TeamID int `bun:",notnull"`
NominatedByUserID int `bun:",notnull"`
NominatedAt int64 `bun:",notnull"`
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
Team *Team `bun:"rel:belongs-to,join:team_id=id"`
NominatedBy *User `bun:"rel:belongs-to,join:nominated_by_user_id=id"`
}
// RegisterFreeAgent registers a player as a free agent in a season_league.
func RegisterFreeAgent(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID, playerID int,
audit *AuditMeta,
) error {
user := CurrentUser(ctx)
if user == nil {
return errors.New("user cannot be nil")
}
entry := &SeasonLeagueFreeAgent{
SeasonID: seasonID,
LeagueID: leagueID,
PlayerID: playerID,
RegisteredAt: time.Now().Unix(),
RegisteredByUserID: user.ID,
}
info := &AuditInfo{
Action: "free_agents.add",
ResourceType: "season_league_free_agent",
ResourceID: fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
Details: map[string]any{
"season_id": seasonID,
"league_id": leagueID,
"player_id": playerID,
},
}
err := Insert(tx, entry).WithAudit(audit, info).Exec(ctx)
if err != nil {
return errors.Wrap(err, "Insert")
}
return nil
}
// UnregisterFreeAgent removes a player's free agent registration and all their nominations.
func UnregisterFreeAgent(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID, playerID int,
audit *AuditMeta,
) error {
// First remove all nominations for this player
err := RemoveAllFreeAgentNominationsForPlayer(ctx, tx, playerID)
if err != nil {
return errors.Wrap(err, "RemoveAllFreeAgentNominationsForPlayer")
}
// Then remove the registration
_, err = tx.NewDelete().
Model((*SeasonLeagueFreeAgent)(nil)).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("player_id = ?", playerID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
info := &AuditInfo{
Action: "free_agents.remove",
ResourceType: "season_league_free_agent",
ResourceID: fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
Details: map[string]any{
"season_id": seasonID,
"league_id": leagueID,
"player_id": playerID,
},
}
err = LogSuccess(ctx, tx, audit, info)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
return nil
}
// GetFreeAgentsForSeasonLeague returns all players registered as free agents in a season_league.
func GetFreeAgentsForSeasonLeague(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*SeasonLeagueFreeAgent, error) {
entries := []*SeasonLeagueFreeAgent{}
err := tx.NewSelect().
Model(&entries).
Where("slfa.season_id = ?", seasonID).
Where("slfa.league_id = ?", leagueID).
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("User")
}).
Relation("RegisteredBy").
Order("slfa.registered_at ASC").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return entries, nil
}
// IsFreeAgentRegistered checks if a player is registered as a free agent in a season_league.
func IsFreeAgentRegistered(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID, playerID int,
) (bool, error) {
count, err := tx.NewSelect().
Model((*SeasonLeagueFreeAgent)(nil)).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("player_id = ?", playerID).
Count(ctx)
if err != nil {
return false, errors.Wrap(err, "tx.NewSelect")
}
return count > 0, nil
}
// NominateFreeAgent nominates a free agent for a specific fixture on behalf of a team.
func NominateFreeAgent(
ctx context.Context,
tx bun.Tx,
fixtureID, playerID, teamID int,
audit *AuditMeta,
) error {
user := CurrentUser(ctx)
if user == nil {
return errors.New("user cannot be nil")
}
// Check if already nominated by another team
existing := new(FixtureFreeAgent)
err := tx.NewSelect().
Model(existing).
Where("ffa.fixture_id = ?", fixtureID).
Where("ffa.player_id = ?", playerID).
Scan(ctx)
if err == nil {
// Found existing nomination
if existing.TeamID != teamID {
return BadRequest("Player already nominated for this fixture by another team")
}
return BadRequest("Player already nominated for this fixture")
}
if err.Error() != "sql: no rows in result set" {
return errors.Wrap(err, "tx.NewSelect")
}
// Check max 2 free agents per team per fixture
count, err := tx.NewSelect().
Model((*FixtureFreeAgent)(nil)).
Where("fixture_id = ?", fixtureID).
Where("team_id = ?", teamID).
Count(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewSelect count")
}
if count >= 2 {
return BadRequest("Maximum of 2 free agents per team per fixture")
}
entry := &FixtureFreeAgent{
FixtureID: fixtureID,
PlayerID: playerID,
TeamID: teamID,
NominatedByUserID: user.ID,
NominatedAt: time.Now().Unix(),
}
info := &AuditInfo{
Action: "free_agents.nominate",
ResourceType: "fixture_free_agent",
ResourceID: fmt.Sprintf("%d-%d", fixtureID, playerID),
Details: map[string]any{
"fixture_id": fixtureID,
"player_id": playerID,
"team_id": teamID,
},
}
err = Insert(tx, entry).WithAudit(audit, info).Exec(ctx)
if err != nil {
return errors.Wrap(err, "Insert")
}
return nil
}
// GetNominatedFreeAgents returns all free agents nominated for a fixture.
func GetNominatedFreeAgents(
ctx context.Context,
tx bun.Tx,
fixtureID int,
) ([]*FixtureFreeAgent, error) {
entries := []*FixtureFreeAgent{}
err := tx.NewSelect().
Model(&entries).
Where("ffa.fixture_id = ?", fixtureID).
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("User")
}).
Relation("Team").
Relation("NominatedBy").
Order("ffa.nominated_at ASC").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return entries, nil
}
// GetNominatedFreeAgentsByTeam returns free agents nominated by a specific team for a fixture.
func GetNominatedFreeAgentsByTeam(
ctx context.Context,
tx bun.Tx,
fixtureID, teamID int,
) ([]*FixtureFreeAgent, error) {
entries := []*FixtureFreeAgent{}
err := tx.NewSelect().
Model(&entries).
Where("ffa.fixture_id = ?", fixtureID).
Where("ffa.team_id = ?", teamID).
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("User")
}).
Order("ffa.nominated_at ASC").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return entries, nil
}
// RemoveAllFreeAgentNominationsForPlayer deletes all nominations for a player.
// Used for cascade deletion on team join and unregister.
func RemoveAllFreeAgentNominationsForPlayer(
ctx context.Context,
tx bun.Tx,
playerID int,
) error {
_, err := tx.NewDelete().
Model((*FixtureFreeAgent)(nil)).
Where("player_id = ?", playerID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
return nil
}
// RemoveFreeAgentNomination removes a specific nomination.
func RemoveFreeAgentNomination(
ctx context.Context,
tx bun.Tx,
fixtureID, playerID int,
audit *AuditMeta,
) error {
_, err := tx.NewDelete().
Model((*FixtureFreeAgent)(nil)).
Where("fixture_id = ?", fixtureID).
Where("player_id = ?", playerID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
info := &AuditInfo{
Action: "free_agents.remove_nomination",
ResourceType: "fixture_free_agent",
ResourceID: fmt.Sprintf("%d-%d", fixtureID, playerID),
Details: map[string]any{
"fixture_id": fixtureID,
"player_id": playerID,
},
}
err = LogSuccess(ctx, tx, audit, info)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
return nil
}
// RemoveFreeAgentRegistrationForPlayer removes all free agent registrations for a player.
// Used on team join.
func RemoveFreeAgentRegistrationForPlayer(
ctx context.Context,
tx bun.Tx,
playerID int,
) error {
_, err := tx.NewDelete().
Model((*SeasonLeagueFreeAgent)(nil)).
Where("player_id = ?", playerID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
return nil
}

View File

@@ -37,6 +37,10 @@ func (g *fieldgetter[T]) Get(ctx context.Context) (*T, error) {
return g.get(ctx) return g.get(ctx)
} }
func (g *fieldgetter[T]) String() string {
return g.q.String()
}
func (g *fieldgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *fieldgetter[T] { func (g *fieldgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *fieldgetter[T] {
g.q = g.q.Relation(name, apply...) g.q = g.q.Relation(name, apply...)
return g return g
@@ -66,5 +70,6 @@ func GetByID[T any](
tx bun.Tx, tx bun.Tx,
id int, id int,
) *fieldgetter[T] { ) *fieldgetter[T] {
return GetByField[T](tx, "id", id) prefix := extractTableAlias[T]()
return GetByField[T](tx, prefix+".id", id)
} }

View File

@@ -3,7 +3,6 @@ package db
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@@ -110,7 +109,6 @@ func (l *listgetter[T]) Filter(filters ...Filter) *listgetter[T] {
l.q = l.q.Where("? ? ?", bun.Ident(filter.Field), bun.Safe(filter.Comparator), filter.Value) l.q = l.q.Where("? ? ?", bun.Ident(filter.Field), bun.Safe(filter.Comparator), filter.Value)
} }
} }
fmt.Println(l.q.String())
return l return l
} }

View File

@@ -498,14 +498,23 @@ func ResetDatabase(ctx context.Context, cfg *config.Config) error {
conn := db.NewDB(cfg.DB) conn := db.NewDB(cfg.DB)
defer func() { _ = conn.Close() }() defer func() { _ = conn.Close() }()
models := conn.RegisterModels() conn.RegisterModels()
for _, model := range models { err = RunMigrations(ctx, cfg, "rollback", "all")
if err := conn.ResetModel(ctx, model); err != nil { if err != nil {
return errors.Wrap(err, "reset model") return errors.Wrap(err, "RunMigrations: rollback")
} }
err = RunMigrations(ctx, cfg, "up", "all")
if err != nil {
return errors.Wrap(err, "RunMigrations: up")
} }
// for _, model := range models {
// if err := conn.ResetModel(ctx, model); err != nil {
// return errors.Wrap(err, "reset model")
// }
// }
fmt.Println("✅ Database reset complete") fmt.Println("✅ Database reset complete")
return nil return nil
} }

View File

@@ -55,13 +55,7 @@ func init() {
if err != nil { if err != nil {
return err return err
} }
return nil
// Remove slap_version column from seasons table
_, err = conn.NewDropColumn().
Model((*db.Season)(nil)).
ColumnExpr("slap_version").
Exec(ctx)
return err
}, },
) )
} }

View File

@@ -0,0 +1,37 @@
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 your migration code here
_, err := conn.NewCreateTable().
Model((*db.Player)(nil)).
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here
_, err := conn.NewDropTable().
Model((*db.Player)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
)
}

View File

@@ -0,0 +1,63 @@
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 your migration code here
_, err := conn.NewAddColumn().
Model((*db.Season)(nil)).
IfNotExists().
ColumnExpr("type VARCHAR NOT NULL").
Exec(ctx)
if err != nil {
return err
}
leagues := []db.League{
{
Name: "Pro League",
ShortName: "Pro",
Description: "For the most experienced Slapshotters in OSL",
},
{
Name: "Intermediate League",
ShortName: "IM",
Description: "For returning players who've been practicing in RPUGs and PUBs",
},
{
Name: "Open League",
ShortName: "Open",
Description: "For new players just getting started with Slapshot",
},
{
Name: "Draft League",
ShortName: "Draft",
Description: "A league where teams are selected by a draft system",
},
}
for _, league := range leagues {
_, err = conn.NewInsert().
Model(&league).
On("CONFLICT DO NOTHING").
Exec(ctx)
if err != nil {
return err
}
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here
return nil
},
)
}

View File

@@ -0,0 +1,31 @@
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 your migration code here
_, err := conn.NewAddColumn().
Model((*db.Player)(nil)).
IfNotExists().
ColumnExpr("name VARCHAR NOT NULL").
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here
return nil
},
)
}

View File

@@ -0,0 +1,30 @@
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 your migration code here
_, err := conn.NewCreateTable().
IfNotExists().
Model((*db.TeamRoster)(nil)).
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here
return nil
},
)
}

View File

@@ -0,0 +1,58 @@
package migrations
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, conn *bun.DB) error {
_, err := conn.NewCreateTable().
Model((*db.FixtureSchedule)(nil)).
IfNotExists().
ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`).
ForeignKey(`("proposed_by_team_id") REFERENCES "teams" ("id")`).
ForeignKey(`("accepted_by_team_id") REFERENCES "teams" ("id")`).
Exec(ctx)
if err != nil {
return err
}
// Create index on fixture_id for faster lookups
_, err = conn.NewCreateIndex().
Model((*db.FixtureSchedule)(nil)).
Index("idx_fixture_schedules_fixture_id").
Column("fixture_id").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Create index on status for filtering
_, err = conn.NewCreateIndex().
Model((*db.FixtureSchedule)(nil)).
Index("idx_fixture_schedules_status").
Column("status").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
_, err := conn.NewDropTable().
Model((*db.FixtureSchedule)(nil)).
IfExists().
Exec(ctx)
return err
},
)
}

View File

@@ -0,0 +1,91 @@
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 {
// Create fixture_results table
_, err := conn.NewCreateTable().
Model((*db.FixtureResult)(nil)).
IfNotExists().
ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`).
ForeignKey(`("uploaded_by_user_id") REFERENCES "users" ("id")`).
Exec(ctx)
if err != nil {
return err
}
// Create fixture_result_player_stats table
_, err = conn.NewCreateTable().
Model((*db.FixtureResultPlayerStats)(nil)).
IfNotExists().
ForeignKey(`("fixture_result_id") REFERENCES "fixture_results" ("id") ON DELETE CASCADE`).
ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE SET NULL`).
ForeignKey(`("team_id") REFERENCES "teams" ("id") ON DELETE SET NULL`).
Exec(ctx)
if err != nil {
return err
}
// Create index on fixture_result_id for faster lookups
_, err = conn.NewCreateIndex().
Model((*db.FixtureResultPlayerStats)(nil)).
Index("idx_frps_fixture_result_id").
Column("fixture_result_id").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Create index on player_id for stats queries
_, err = conn.NewCreateIndex().
Model((*db.FixtureResultPlayerStats)(nil)).
Index("idx_frps_player_id").
Column("player_id").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Create composite index for period+team filtering
_, err = conn.NewCreateIndex().
Model((*db.FixtureResultPlayerStats)(nil)).
Index("idx_frps_result_period_team").
Column("fixture_result_id", "period_num", "team").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Drop fixture_result_player_stats first (has FK to fixture_results)
_, err := conn.NewDropTable().
Model((*db.FixtureResultPlayerStats)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return err
}
// Drop fixture_results
_, err = conn.NewDropTable().
Model((*db.FixtureResult)(nil)).
IfExists().
Exec(ctx)
return err
},
)
}

View File

@@ -0,0 +1,91 @@
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 {
// Create season_league_free_agents table
_, err := conn.NewCreateTable().
Model((*db.SeasonLeagueFreeAgent)(nil)).
IfNotExists().
ForeignKey(`("season_id") REFERENCES "seasons" ("id") ON DELETE CASCADE`).
ForeignKey(`("league_id") REFERENCES "leagues" ("id") ON DELETE CASCADE`).
ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE CASCADE`).
ForeignKey(`("registered_by_user_id") REFERENCES "users" ("id")`).
Exec(ctx)
if err != nil {
return err
}
// Create fixture_free_agents table
_, err = conn.NewCreateTable().
Model((*db.FixtureFreeAgent)(nil)).
IfNotExists().
ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`).
ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE CASCADE`).
ForeignKey(`("team_id") REFERENCES "teams" ("id") ON DELETE CASCADE`).
ForeignKey(`("nominated_by_user_id") REFERENCES "users" ("id")`).
Exec(ctx)
if err != nil {
return err
}
// Create index on fixture_free_agents for team lookups
_, err = conn.NewCreateIndex().
Model((*db.FixtureFreeAgent)(nil)).
Index("idx_ffa_fixture_team").
Column("fixture_id", "team_id").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Add is_free_agent column to fixture_result_player_stats
_, err = conn.NewAddColumn().
Model((*db.FixtureResultPlayerStats)(nil)).
ColumnExpr("is_free_agent BOOLEAN NOT NULL DEFAULT false").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Drop is_free_agent column from fixture_result_player_stats
_, err := conn.NewDropColumn().
Model((*db.FixtureResultPlayerStats)(nil)).
ColumnExpr("is_free_agent").
Exec(ctx)
if err != nil {
return err
}
// Drop fixture_free_agents table
_, err = conn.NewDropTable().
Model((*db.FixtureFreeAgent)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return err
}
// Drop season_league_free_agents table
_, err = conn.NewDropTable().
Model((*db.SeasonLeagueFreeAgent)(nil)).
IfExists().
Exec(ctx)
return err
},
)
}

112
internal/db/player.go Normal file
View File

@@ -0,0 +1,112 @@
package db
import (
"context"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type Player struct {
bun.BaseModel `bun:"table:players,alias:p"`
ID int `bun:"id,pk,autoincrement" json:"id"`
SlapID *uint32 `bun:"slap_id,unique" json:"slap_id"`
DiscordID string `bun:"discord_id,unique,notnull" json:"discord_id"`
UserID *int `bun:"user_id,unique" json:"user_id"`
Name string `bun:"name,notnull" json:"name"`
User *User `bun:"rel:belongs-to,join:user_id=id" json:"-"`
}
func (p *Player) DisplayName() string {
if p.User != nil {
return p.User.Username
}
return p.Name
}
// NewPlayer creates a new player in the database. If there is an existing user with the same
// discordID, it will automatically link that user to the player
func NewPlayer(ctx context.Context, tx bun.Tx, discordID string, audit *AuditMeta) (*Player, error) {
player := &Player{DiscordID: discordID}
user, err := GetUserByDiscordID(ctx, tx, discordID)
if err != nil && !IsBadRequest(err) {
return nil, errors.Wrap(err, "GetUserByDiscordID")
}
if user != nil {
player.UserID = &user.ID
}
err = Insert(tx, player).
WithAudit(audit, nil).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert")
}
return player, nil
}
// ConnectPlayer links the user to an existing player, or creates a new player to link if not found
// Populates User.Player on success
func (u *User) ConnectPlayer(ctx context.Context, tx bun.Tx, audit *AuditMeta) error {
player, err := GetByField[Player](tx, "p.discord_id", u.DiscordID).
Relation("User").Get(ctx)
if err != nil {
if !IsBadRequest(err) {
// Unexpected error occured
return errors.Wrap(err, "GetByField")
}
// Player doesn't exist, create a new one
player, err = NewPlayer(ctx, tx, u.DiscordID, audit)
if err != nil {
return errors.Wrap(err, "NewPlayer")
}
// New player should automatically get linked to the user
u.Player = player
return nil
}
// Player was found
if player.UserID != nil {
if player.UserID == &u.ID {
return nil
}
return errors.New("player with that discord_id already linked to a user")
}
player.UserID = &u.ID
err = UpdateByID(tx, player.ID, player).Column("user_id").Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
u.Player = player
return nil
}
func GetPlayer(ctx context.Context, tx bun.Tx, playerID int) (*Player, error) {
return GetByID[Player](tx, playerID).Relation("User").Get(ctx)
}
func UpdatePlayerSlapID(ctx context.Context, tx bun.Tx, playerID int, slapID uint32, audit *AuditMeta) error {
player, err := GetPlayer(ctx, tx, playerID)
if err != nil {
return errors.Wrap(err, "GetPlayer")
}
player.SlapID = &slapID
err = UpdateByID(tx, player.ID, player).Column("slap_id").
WithAudit(audit, nil).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}
func GetPlayersNotOnTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Player, error) {
players, err := GetList[Player](tx).Relation("User").
Join("LEFT JOIN team_rosters tr ON tr.player_id = p.id").
Where("NOT (tr.season_id = ? and tr.league_id = ?) OR (tr.season_id IS NULL and tr.league_id IS NULL)",
seasonID, leagueID).
Order("p.name ASC").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
return players, nil
}

View File

@@ -25,6 +25,17 @@ const (
StatusCompleted SeasonStatus = "completed" StatusCompleted SeasonStatus = "completed"
) )
type SeasonType string
func (s SeasonType) String() string {
return string(s)
}
const (
SeasonTypeRegular SeasonType = "regular"
SeasonTypeDraft SeasonType = "draft"
)
type Season struct { type Season struct {
bun.BaseModel `bun:"table:seasons,alias:s"` bun.BaseModel `bun:"table:seasons,alias:s"`
@@ -36,13 +47,14 @@ type Season struct {
FinalsStartDate bun.NullTime `bun:"finals_start_date" json:"finals_start_date"` FinalsStartDate bun.NullTime `bun:"finals_start_date" json:"finals_start_date"`
FinalsEndDate bun.NullTime `bun:"finals_end_date" json:"finals_end_date"` FinalsEndDate bun.NullTime `bun:"finals_end_date" json:"finals_end_date"`
SlapVersion string `bun:"slap_version,notnull,default:'rebound'" json:"slap_version"` SlapVersion string `bun:"slap_version,notnull,default:'rebound'" json:"slap_version"`
Type string `bun:"type,notnull" json:"type"`
Leagues []League `bun:"m2m:season_leagues,join:Season=League" json:"-"` Leagues []League `bun:"m2m:season_leagues,join:Season=League" json:"-"`
Teams []Team `bun:"m2m:team_participations,join:Season=Team" json:"-"` Teams []Team `bun:"m2m:team_participations,join:Season=Team" json:"-"`
} }
// NewSeason creats a new season // NewSeason creats a new season
func NewSeason(ctx context.Context, tx bun.Tx, name, version, shortname string, func NewSeason(ctx context.Context, tx bun.Tx, name, version, shortname, type_ string,
start time.Time, audit *AuditMeta, start time.Time, audit *AuditMeta,
) (*Season, error) { ) (*Season, error) {
season := &Season{ season := &Season{
@@ -50,12 +62,19 @@ func NewSeason(ctx context.Context, tx bun.Tx, name, version, shortname string,
ShortName: strings.ToUpper(shortname), ShortName: strings.ToUpper(shortname),
StartDate: start.Truncate(time.Hour * 24), StartDate: start.Truncate(time.Hour * 24),
SlapVersion: version, SlapVersion: version,
Type: type_,
} }
err := Insert(tx, season). err := Insert(tx, season).
WithAudit(audit, nil).Exec(ctx) WithAudit(audit, nil).Exec(ctx)
if err != nil { if err != nil {
return nil, errors.WithMessage(err, "db.Insert") return nil, errors.WithMessage(err, "db.Insert")
} }
if season.Type == SeasonTypeDraft.String() {
err = NewSeasonLeague(ctx, tx, season.ShortName, "Draft", audit)
if err != nil {
return nil, errors.Wrap(err, "NewSeasonLeague")
}
}
return season, nil return season, nil
} }

View File

@@ -2,6 +2,7 @@ package db
import ( import (
"context" "context"
"database/sql"
"git.haelnorr.com/h/oslstats/internal/permissions" "git.haelnorr.com/h/oslstats/internal/permissions"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -15,8 +16,36 @@ type SeasonLeague struct {
League *League `bun:"rel:belongs-to,join:league_id=id"` League *League `bun:"rel:belongs-to,join:league_id=id"`
} }
// GetSeasonLeague retrieves a specific season-league combination with teams // GetSeasonLeague retrieves a specific season-league combination
func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) { func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*SeasonLeague, error) {
if seasonShortName == "" {
return nil, errors.New("season short_name cannot be empty")
}
if leagueShortName == "" {
return nil, errors.New("league short_name cannot be empty")
}
sl := new(SeasonLeague)
err := tx.NewSelect().
Model(sl).
Relation("Season", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("season.short_name = ?", seasonShortName)
}).
Relation("League", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("league.short_name = ?", leagueShortName)
}).Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, BadRequestNotFound("season_league", "season.short_name,league.short_name", seasonShortName+","+leagueShortName)
}
return nil, errors.Wrap(err, "tx.NewSelect")
}
return sl, nil
}
// GetSeasonLeagueWithTeams retrieves a specific season-league combination with teams
func GetSeasonLeagueWithTeams(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) {
if seasonShortName == "" { if seasonShortName == "" {
return nil, nil, nil, errors.New("season short_name cannot be empty") return nil, nil, nil, errors.New("season short_name cannot be empty")
} }
@@ -41,6 +70,9 @@ func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShor
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id"). Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
Where("tp.season_id = ? AND tp.league_id = ?", season.ID, league.ID). Where("tp.season_id = ? AND tp.league_id = ?", season.ID, league.ID).
Order("t.name ASC"). Order("t.name ASC").
Relation("Players", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("season_id = ? AND league_id = ?", season.ID, league.ID)
}).
Scan(ctx) Scan(ctx)
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "tx.Select teams") return nil, nil, nil, errors.Wrap(err, "tx.Select teams")

View File

@@ -24,6 +24,7 @@ func (db *DB) RegisterModels() []any {
(*UserRole)(nil), (*UserRole)(nil),
(*SeasonLeague)(nil), (*SeasonLeague)(nil),
(*TeamParticipation)(nil), (*TeamParticipation)(nil),
(*TeamRoster)(nil),
(*User)(nil), (*User)(nil),
(*DiscordToken)(nil), (*DiscordToken)(nil),
(*Season)(nil), (*Season)(nil),
@@ -33,6 +34,10 @@ func (db *DB) RegisterModels() []any {
(*Permission)(nil), (*Permission)(nil),
(*AuditLog)(nil), (*AuditLog)(nil),
(*Fixture)(nil), (*Fixture)(nil),
(*FixtureSchedule)(nil),
(*Player)(nil),
(*FixtureResult)(nil),
(*FixtureResultPlayerStats)(nil),
} }
db.RegisterModel(models...) db.RegisterModel(models...)
return models return models

View File

@@ -17,6 +17,7 @@ type Team struct {
Seasons []Season `bun:"m2m:team_participations,join:Team=Season" json:"-"` Seasons []Season `bun:"m2m:team_participations,join:Team=Season" json:"-"`
Leagues []League `bun:"m2m:team_participations,join:Team=League" json:"-"` Leagues []League `bun:"m2m:team_participations,join:Team=League" json:"-"`
Players []Player `bun:"m2m:team_rosters,join:Team=Player" json:"-"`
} }
func NewTeam(ctx context.Context, tx bun.Tx, name, shortName, altShortName, color string, audit *AuditMeta) (*Team, error) { func NewTeam(ctx context.Context, tx bun.Tx, name, shortName, altShortName, color string, audit *AuditMeta) (*Team, error) {

247
internal/db/teamroster.go Normal file
View File

@@ -0,0 +1,247 @@
package db
import (
"context"
"fmt"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type TeamRoster struct {
bun.BaseModel `bun:"table:team_rosters,alias:tr"`
TeamID int `bun:",pk,notnull" json:"team_id"`
SeasonID int `bun:",pk,notnull,unique:player" json:"season_id"`
LeagueID int `bun:",pk,notnull,unique:player" json:"league_id"`
PlayerID int `bun:",pk,notnull,unique:player" json:"player_id"`
IsManager bool `bun:"is_manager,default:'false'" json:"is_manager"`
Team *Team `bun:"rel:belongs-to,join:team_id=id" json:"-"`
Player *Player `bun:"rel:belongs-to,join:player_id=id" json:"-"`
Season *Season `bun:"rel:belongs-to,join:season_id=id" json:"-"`
League *League `bun:"rel:belongs-to,join:league_id=id" json:"-"`
}
type TeamWithRoster struct {
Team *Team
Season *Season
League *League
Manager *Player
Players []*Player
}
func GetTeamRoster(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, teamID int) (*TeamWithRoster, error) {
tr := []*TeamRoster{}
err := tx.NewSelect().
Model(&tr).
Relation("Team", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("team.id = ?", teamID)
}).
Relation("Season", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("season.short_name = ?", seasonShortName)
}).
Relation("League", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("league.short_name = ?", leagueShortName)
}).
Relation("Player").Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
team, err := GetTeam(ctx, tx, teamID)
if err != nil {
return nil, errors.Wrap(err, "GetTeam")
}
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return nil, errors.Wrap(err, "GetSeasonLeague")
}
var manager *Player
players := []*Player{}
for _, tp := range tr {
if tp.IsManager {
manager = tp.Player
} else {
players = append(players, tp.Player)
}
}
players = append([]*Player{manager}, players...)
twr := &TeamWithRoster{
team,
sl.Season,
sl.League,
manager,
players,
}
return twr, nil
}
// GetManagersByTeam returns a map of teamID -> manager Player for all teams in a season/league
func GetManagersByTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) (map[int]*Player, error) {
rosters := []*TeamRoster{}
err := tx.NewSelect().
Model(&rosters).
Where("tr.season_id = ?", seasonID).
Where("tr.league_id = ?", leagueID).
Where("tr.is_manager = true").
Relation("Player").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
result := make(map[int]*Player, len(rosters))
for _, r := range rosters {
result[r.TeamID] = r.Player
}
return result, nil
}
func AddPlayerToTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID, playerID int, manager bool, audit *AuditMeta) error {
season, err := GetByID[Season](tx, seasonID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetSeason")
}
league, err := GetByID[League](tx, leagueID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetLeague")
}
team, err := GetByID[Team](tx, teamID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetTeam")
}
player, err := GetByID[Player](tx, playerID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetPlayer")
}
tr := &TeamRoster{
SeasonID: season.ID,
LeagueID: league.ID,
TeamID: team.ID,
PlayerID: player.ID,
IsManager: manager,
}
err = Insert(tx, tr).WithAudit(audit, nil).Exec(ctx)
if err != nil {
return errors.Wrap(err, "Insert")
}
return nil
}
// ManageTeamRoster replaces the entire roster for a team in a season/league.
// It deletes all existing roster entries and inserts the new ones.
// Also auto-removes free agent registrations and nominations for players joining a team.
func ManageTeamRoster(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID, managerID int, playerIDs []int, audit *AuditMeta) error {
// Delete all existing roster entries for this team/season/league
_, err := tx.NewDelete().
Model((*TeamRoster)(nil)).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("team_id = ?", teamID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "delete existing roster")
}
// Collect all player IDs being added (including manager)
allPlayerIDs := make([]int, 0, len(playerIDs)+1)
if managerID > 0 {
allPlayerIDs = append(allPlayerIDs, managerID)
}
for _, pid := range playerIDs {
if pid != managerID {
allPlayerIDs = append(allPlayerIDs, pid)
}
}
// Auto-remove free agent registrations and nominations for players joining a team
for _, playerID := range allPlayerIDs {
// Check if the player is a registered free agent
isFA, err := IsFreeAgentRegistered(ctx, tx, seasonID, leagueID, playerID)
if err != nil {
return errors.Wrap(err, "IsFreeAgentRegistered")
}
if isFA {
// Remove all nominations for this player
err = RemoveAllFreeAgentNominationsForPlayer(ctx, tx, playerID)
if err != nil {
return errors.Wrap(err, "RemoveAllFreeAgentNominationsForPlayer")
}
// Remove free agent registration
err = RemoveFreeAgentRegistrationForPlayer(ctx, tx, playerID)
if err != nil {
return errors.Wrap(err, "RemoveFreeAgentRegistrationForPlayer")
}
// Log the cascade action
if audit != nil {
cascadeInfo := &AuditInfo{
"free_agents.auto_removed_on_team_join",
"season_league_free_agent",
fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
map[string]any{
"season_id": seasonID,
"league_id": leagueID,
"player_id": playerID,
"team_id": teamID,
},
}
err = LogSuccess(ctx, tx, audit, cascadeInfo)
if err != nil {
return errors.Wrap(err, "LogSuccess cascade")
}
}
}
}
// Insert manager if provided
if managerID > 0 {
tr := &TeamRoster{
SeasonID: seasonID,
LeagueID: leagueID,
TeamID: teamID,
PlayerID: managerID,
IsManager: true,
}
err = Insert(tx, tr).Exec(ctx)
if err != nil {
return errors.Wrap(err, "Insert manager")
}
}
// Insert players
for _, playerID := range playerIDs {
if playerID == managerID {
continue // Already inserted as manager
}
tr := &TeamRoster{
SeasonID: seasonID,
LeagueID: leagueID,
TeamID: teamID,
PlayerID: playerID,
IsManager: false,
}
err = Insert(tx, tr).Exec(ctx)
if err != nil {
return errors.Wrap(err, "Insert player")
}
}
// Log the roster change
details := map[string]any{
"season_id": seasonID,
"league_id": leagueID,
"team_id": teamID,
"manager_id": managerID,
"player_ids": playerIDs,
}
info := &AuditInfo{
"teams.manage_players",
"team_roster",
fmt.Sprintf("%d-%d-%d", seasonID, leagueID, teamID),
details,
}
err = LogSuccess(ctx, tx, audit, info)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
return nil
}

View File

@@ -21,6 +21,7 @@ type User struct {
DiscordID string `bun:"discord_id,unique" json:"discord_id"` DiscordID string `bun:"discord_id,unique" json:"discord_id"`
Roles []*Role `bun:"m2m:user_roles,join:User=Role" json:"-"` Roles []*Role `bun:"m2m:user_roles,join:User=Role" json:"-"`
Player *Player `bun:"rel:has-one,join:id=user_id"`
} }
func (u *User) GetID() int { func (u *User) GetID() int {
@@ -55,7 +56,7 @@ func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *di
// GetUserByID queries the database for a user matching the given ID // GetUserByID queries the database for a user matching the given ID
// Returns a BadRequestNotFound error if no user is found // Returns a BadRequestNotFound error if no user is found
func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) { func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) {
return GetByID[User](tx, id).Get(ctx) return GetByID[User](tx, id).Relation("Player").Get(ctx)
} }
// GetUserByUsername queries the database for a user matching the given username // GetUserByUsername queries the database for a user matching the given username
@@ -64,7 +65,7 @@ func GetUserByUsername(ctx context.Context, tx bun.Tx, username string) (*User,
if username == "" { if username == "" {
return nil, errors.New("username not provided") return nil, errors.New("username not provided")
} }
return GetByField[User](tx, "username", username).Get(ctx) return GetByField[User](tx, "username", username).Relation("Player").Get(ctx)
} }
// GetUserByDiscordID queries the database for a user matching the given discord id // GetUserByDiscordID queries the database for a user matching the given discord id
@@ -73,7 +74,7 @@ func GetUserByDiscordID(ctx context.Context, tx bun.Tx, discordID string) (*User
if discordID == "" { if discordID == "" {
return nil, errors.New("discord_id not provided") return nil, errors.New("discord_id not provided")
} }
return GetByField[User](tx, "discord_id", discordID).Get(ctx) return GetByField[User](tx, "u.discord_id", discordID).Relation("Player").Get(ctx)
} }
// GetRoles loads all the roles for this user // GetRoles loads all the roles for this user
@@ -141,7 +142,7 @@ func (u *User) IsAdmin(ctx context.Context, tx bun.Tx) (bool, error) {
func GetUsers(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[User], error) { func GetUsers(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[User], error) {
defaults := &PageOpts{1, 50, bun.OrderAsc, "id"} defaults := &PageOpts{1, 50, bun.OrderAsc, "id"}
return GetList[User](tx).GetPaged(ctx, pageOpts, defaults) return GetList[User](tx).Relation("Player").GetPaged(ctx, pageOpts, defaults)
} }
// GetUsersWithRoles queries the database for users with their roles preloaded // GetUsersWithRoles queries the database for users with their roles preloaded

View File

@@ -1,3 +1,4 @@
// Package discord provides utilities for interacting with the discord API
package discord package discord
import ( import (
@@ -13,6 +14,7 @@ type Config struct {
OAuthScopes string // Authorisation scopes for OAuth OAuthScopes string // Authorisation scopes for OAuth
RedirectPath string // ENV DISCORD_REDIRECT_PATH: Path for the OAuth redirect handler (required) RedirectPath string // ENV DISCORD_REDIRECT_PATH: Path for the OAuth redirect handler (required)
BotToken string // ENV DISCORD_BOT_TOKEN: Token for the discord bot (required) BotToken string // ENV DISCORD_BOT_TOKEN: Token for the discord bot (required)
GuildID string // ENV DISCORD_GUILD_ID: ID for the discord server the bot should connect to (required)
} }
func ConfigFromEnv() (any, error) { func ConfigFromEnv() (any, error) {
@@ -22,6 +24,7 @@ func ConfigFromEnv() (any, error) {
OAuthScopes: getOAuthScopes(), OAuthScopes: getOAuthScopes(),
RedirectPath: env.String("DISCORD_REDIRECT_PATH", ""), RedirectPath: env.String("DISCORD_REDIRECT_PATH", ""),
BotToken: env.String("DISCORD_BOT_TOKEN", ""), BotToken: env.String("DISCORD_BOT_TOKEN", ""),
GuildID: env.String("DISCORD_GUILD_ID", ""),
} }
// Check required fields // Check required fields
@@ -37,6 +40,9 @@ func ConfigFromEnv() (any, error) {
if cfg.BotToken == "" { if cfg.BotToken == "" {
return nil, errors.New("Envar not set: DISCORD_BOT_TOKEN") return nil, errors.New("Envar not set: DISCORD_BOT_TOKEN")
} }
if cfg.GuildID == "" {
return nil, errors.New("Envar not set: DISCORD_GUILD_ID")
}
return cfg, nil return cfg, nil
} }

View File

@@ -19,19 +19,19 @@ type RateLimitState struct {
// Do executes an HTTP request with automatic rate limit handling // Do executes an HTTP request with automatic rate limit handling
// It will wait if rate limits are about to be exceeded and retry once if a 429 is received // It will wait if rate limits are about to be exceeded and retry once if a 429 is received
func (c *APIClient) Do(req *http.Request) (*http.Response, error) { func (api *APIClient) Do(req *http.Request) (*http.Response, error) {
if req == nil { if req == nil {
return nil, errors.New("request cannot be nil") return nil, errors.New("request cannot be nil")
} }
// Step 1: Check if we need to wait before making request // Step 1: Check if we need to wait before making request
bucket := c.getBucketFromRequest(req) bucket := api.getBucketFromRequest(req)
if err := c.waitIfNeeded(bucket); err != nil { if err := api.waitIfNeeded(bucket); err != nil {
return nil, err return nil, err
} }
// Step 2: Execute request // Step 2: Execute request
resp, err := c.client.Do(req) resp, err := api.client.Do(req)
if err != nil { if err != nil {
// Check if it's a network timeout // Check if it's a network timeout
if netErr, ok := err.(net.Error); ok && netErr.Timeout() { if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
@@ -41,17 +41,17 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
} }
// Step 3: Update rate limit state from response headers // Step 3: Update rate limit state from response headers
c.updateRateLimit(resp.Header) api.updateRateLimit(resp.Header)
// Step 4: Handle 429 (rate limited) // Step 4: Handle 429 (rate limited)
if resp.StatusCode == http.StatusTooManyRequests { if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close() // Close original response resp.Body.Close() // Close original response
retryAfter := c.parseRetryAfter(resp.Header) retryAfter := api.parseRetryAfter(resp.Header)
// No Retry-After header, can't retry safely // No Retry-After header, can't retry safely
if retryAfter == 0 { if retryAfter == 0 {
c.logger.Warn(). api.logger.Warn().
Str("bucket", bucket). Str("bucket", bucket).
Str("method", req.Method). Str("method", req.Method).
Str("path", req.URL.Path). Str("path", req.URL.Path).
@@ -61,7 +61,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
// Retry-After exceeds 30 second cap // Retry-After exceeds 30 second cap
if retryAfter > 30*time.Second { if retryAfter > 30*time.Second {
c.logger.Warn(). api.logger.Warn().
Str("bucket", bucket). Str("bucket", bucket).
Str("method", req.Method). Str("method", req.Method).
Str("path", req.URL.Path). Str("path", req.URL.Path).
@@ -74,7 +74,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
} }
// Wait and retry // Wait and retry
c.logger.Warn(). api.logger.Warn().
Str("bucket", bucket). Str("bucket", bucket).
Str("method", req.Method). Str("method", req.Method).
Str("path", req.URL.Path). Str("path", req.URL.Path).
@@ -84,7 +84,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
time.Sleep(retryAfter) time.Sleep(retryAfter)
// Retry the request // Retry the request
resp, err = c.client.Do(req) resp, err = api.client.Do(req)
if err != nil { if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() { if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
return nil, errors.Wrap(err, "retry request timed out") return nil, errors.Wrap(err, "retry request timed out")
@@ -93,12 +93,12 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
} }
// Update rate limit again after retry // Update rate limit again after retry
c.updateRateLimit(resp.Header) api.updateRateLimit(resp.Header)
// If STILL rate limited after retry, return error // If STILL rate limited after retry, return error
if resp.StatusCode == http.StatusTooManyRequests { if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close() resp.Body.Close()
c.logger.Error(). api.logger.Error().
Str("bucket", bucket). Str("bucket", bucket).
Str("method", req.Method). Str("method", req.Method).
Str("path", req.URL.Path). Str("path", req.URL.Path).
@@ -115,15 +115,15 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
// getBucketFromRequest extracts or generates bucket ID from request // getBucketFromRequest extracts or generates bucket ID from request
// For Discord, the bucket is typically METHOD:path until we get the actual bucket from headers // For Discord, the bucket is typically METHOD:path until we get the actual bucket from headers
func (c *APIClient) getBucketFromRequest(req *http.Request) string { func (api *APIClient) getBucketFromRequest(req *http.Request) string {
return req.Method + ":" + req.URL.Path return req.Method + ":" + req.URL.Path
} }
// waitIfNeeded checks if we need to delay before request to avoid hitting rate limits // waitIfNeeded checks if we need to delay before request to avoid hitting rate limits
func (c *APIClient) waitIfNeeded(bucket string) error { func (api *APIClient) waitIfNeeded(bucket string) error {
c.mu.RLock() api.mu.RLock()
state, exists := c.buckets[bucket] state, exists := api.buckets[bucket]
c.mu.RUnlock() api.mu.RUnlock()
if !exists { if !exists {
return nil // No state yet, proceed return nil // No state yet, proceed
@@ -138,7 +138,7 @@ func (c *APIClient) waitIfNeeded(bucket string) error {
waitDuration += 100 * time.Millisecond waitDuration += 100 * time.Millisecond
if waitDuration > 0 { if waitDuration > 0 {
c.logger.Debug(). api.logger.Debug().
Str("bucket", bucket). Str("bucket", bucket).
Dur("wait_duration", waitDuration). Dur("wait_duration", waitDuration).
Msg("Proactively waiting for rate limit reset") Msg("Proactively waiting for rate limit reset")
@@ -150,16 +150,16 @@ func (c *APIClient) waitIfNeeded(bucket string) error {
} }
// updateRateLimit parses response headers and updates bucket state // updateRateLimit parses response headers and updates bucket state
func (c *APIClient) updateRateLimit(headers http.Header) { func (api *APIClient) updateRateLimit(headers http.Header) {
bucket := headers.Get("X-RateLimit-Bucket") bucket := headers.Get("X-RateLimit-Bucket")
if bucket == "" { if bucket == "" {
return // No bucket info, can't track return // No bucket info, can't track
} }
// Parse headers // Parse headers
limit := c.parseInt(headers.Get("X-RateLimit-Limit")) limit := api.parseInt(headers.Get("X-RateLimit-Limit"))
remaining := c.parseInt(headers.Get("X-RateLimit-Remaining")) remaining := api.parseInt(headers.Get("X-RateLimit-Remaining"))
resetAfter := c.parseFloat(headers.Get("X-RateLimit-Reset-After")) resetAfter := api.parseFloat(headers.Get("X-RateLimit-Reset-After"))
state := &RateLimitState{ state := &RateLimitState{
Bucket: bucket, Bucket: bucket,
@@ -168,12 +168,12 @@ func (c *APIClient) updateRateLimit(headers http.Header) {
Reset: time.Now().Add(time.Duration(resetAfter * float64(time.Second))), Reset: time.Now().Add(time.Duration(resetAfter * float64(time.Second))),
} }
c.mu.Lock() api.mu.Lock()
c.buckets[bucket] = state api.buckets[bucket] = state
c.mu.Unlock() api.mu.Unlock()
// Log rate limit state for debugging // Log rate limit state for debugging
c.logger.Debug(). api.logger.Debug().
Str("bucket", bucket). Str("bucket", bucket).
Int("remaining", remaining). Int("remaining", remaining).
Int("limit", limit). Int("limit", limit).
@@ -182,14 +182,14 @@ func (c *APIClient) updateRateLimit(headers http.Header) {
} }
// parseRetryAfter extracts retry delay from Retry-After header // parseRetryAfter extracts retry delay from Retry-After header
func (c *APIClient) parseRetryAfter(headers http.Header) time.Duration { func (api *APIClient) parseRetryAfter(headers http.Header) time.Duration {
retryAfter := headers.Get("Retry-After") retryAfter := headers.Get("Retry-After")
if retryAfter == "" { if retryAfter == "" {
return 0 return 0
} }
// Discord returns seconds as float // Discord returns seconds as float
seconds := c.parseFloat(retryAfter) seconds := api.parseFloat(retryAfter)
if seconds <= 0 { if seconds <= 0 {
return 0 return 0
} }
@@ -198,7 +198,7 @@ func (c *APIClient) parseRetryAfter(headers http.Header) time.Duration {
} }
// parseInt parses an integer from a header value, returns 0 on error // parseInt parses an integer from a header value, returns 0 on error
func (c *APIClient) parseInt(s string) int { func (api *APIClient) parseInt(s string) int {
if s == "" { if s == "" {
return 0 return 0
} }
@@ -207,7 +207,7 @@ func (c *APIClient) parseInt(s string) int {
} }
// parseFloat parses a float from a header value, returns 0 on error // parseFloat parses a float from a header value, returns 0 on error
func (c *APIClient) parseFloat(s string) float64 { func (api *APIClient) parseFloat(s string) float64 {
if s == "" { if s == "" {
return 0 return 0
} }

View File

@@ -0,0 +1,20 @@
package discord
import (
"github.com/pkg/errors"
)
var ErrNoSteam error = errors.New("steam connection not found")
func (s *OAuthSession) GetSteamID() (string, error) {
connections, err := s.UserConnections()
if err != nil {
return "", errors.Wrap(err, "s.UserConnections")
}
for _, conn := range connections {
if conn.Type == "steam" {
return conn.ID, nil
}
}
return "", ErrNoSteam
}

View File

@@ -9,6 +9,7 @@
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace; monospace;
--spacing: 0.25rem; --spacing: 0.25rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem; --breakpoint-lg: 64rem;
--breakpoint-xl: 80rem; --breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem; --breakpoint-2xl: 96rem;
@@ -17,6 +18,7 @@
--container-lg: 32rem; --container-lg: 32rem;
--container-2xl: 42rem; --container-2xl: 42rem;
--container-3xl: 48rem; --container-3xl: 48rem;
--container-4xl: 56rem;
--container-5xl: 64rem; --container-5xl: 64rem;
--container-7xl: 80rem; --container-7xl: 80rem;
--text-xs: 0.75rem; --text-xs: 0.75rem;
@@ -37,6 +39,7 @@
--text-6xl--line-height: 1; --text-6xl--line-height: 1;
--text-9xl: 8rem; --text-9xl: 8rem;
--text-9xl--line-height: 1; --text-9xl--line-height: 1;
--font-weight-light: 300;
--font-weight-normal: 400; --font-weight-normal: 400;
--font-weight-medium: 500; --font-weight-medium: 500;
--font-weight-semibold: 600; --font-weight-semibold: 600;
@@ -213,6 +216,9 @@
.collapse { .collapse {
visibility: collapse; visibility: collapse;
} }
.invisible {
visibility: hidden;
}
.visible { .visible {
visibility: visible; visibility: visible;
} }
@@ -260,6 +266,9 @@
.top-20 { .top-20 {
top: calc(var(--spacing) * 20); top: calc(var(--spacing) * 20);
} }
.top-full {
top: 100%;
}
.right-0 { .right-0 {
right: calc(var(--spacing) * 0); right: calc(var(--spacing) * 0);
} }
@@ -284,6 +293,12 @@
.z-50 { .z-50 {
z-index: 50; z-index: 50;
} }
.col-span-1 {
grid-column: span 1 / span 1;
}
.col-span-2 {
grid-column: span 2 / span 2;
}
.container { .container {
width: 100%; width: 100%;
@media (width >= 40rem) { @media (width >= 40rem) {
@@ -308,6 +323,9 @@
.-mt-2 { .-mt-2 {
margin-top: calc(var(--spacing) * -2); margin-top: calc(var(--spacing) * -2);
} }
.-mt-3 {
margin-top: calc(var(--spacing) * -3);
}
.mt-0\.5 { .mt-0\.5 {
margin-top: calc(var(--spacing) * 0.5); margin-top: calc(var(--spacing) * 0.5);
} }
@@ -441,6 +459,9 @@
.h-16 { .h-16 {
height: calc(var(--spacing) * 16); height: calc(var(--spacing) * 16);
} }
.h-\[calc\(100\%-3rem\)\] {
height: calc(100% - 3rem);
}
.h-full { .h-full {
height: 100%; height: 100%;
} }
@@ -450,6 +471,9 @@
.max-h-60 { .max-h-60 {
max-height: calc(var(--spacing) * 60); max-height: calc(var(--spacing) * 60);
} }
.max-h-80 {
max-height: calc(var(--spacing) * 80);
}
.max-h-96 { .max-h-96 {
max-height: calc(var(--spacing) * 96); max-height: calc(var(--spacing) * 96);
} }
@@ -459,6 +483,12 @@
.max-h-\[600px\] { .max-h-\[600px\] {
max-height: 600px; max-height: 600px;
} }
.min-h-12 {
min-height: calc(var(--spacing) * 12);
}
.min-h-40 {
min-height: calc(var(--spacing) * 40);
}
.min-h-48 { .min-h-48 {
min-height: calc(var(--spacing) * 48); min-height: calc(var(--spacing) * 48);
} }
@@ -483,21 +513,27 @@
.w-6 { .w-6 {
width: calc(var(--spacing) * 6); width: calc(var(--spacing) * 6);
} }
.w-10 {
width: calc(var(--spacing) * 10);
}
.w-12 { .w-12 {
width: calc(var(--spacing) * 12); width: calc(var(--spacing) * 12);
} }
.w-20 { .w-20 {
width: calc(var(--spacing) * 20); width: calc(var(--spacing) * 20);
} }
.w-24 {
width: calc(var(--spacing) * 24);
}
.w-26 { .w-26 {
width: calc(var(--spacing) * 26); width: calc(var(--spacing) * 26);
} }
.w-48 { .w-48 {
width: calc(var(--spacing) * 48); width: calc(var(--spacing) * 48);
} }
.w-56 {
width: calc(var(--spacing) * 56);
}
.w-72 {
width: calc(var(--spacing) * 72);
}
.w-80 { .w-80 {
width: calc(var(--spacing) * 80); width: calc(var(--spacing) * 80);
} }
@@ -513,6 +549,9 @@
.max-w-3xl { .max-w-3xl {
max-width: var(--container-3xl); max-width: var(--container-3xl);
} }
.max-w-4xl {
max-width: var(--container-4xl);
}
.max-w-5xl { .max-w-5xl {
max-width: var(--container-5xl); max-width: var(--container-5xl);
} }
@@ -528,6 +567,9 @@
.max-w-100 { .max-w-100 {
max-width: calc(var(--spacing) * 100); max-width: calc(var(--spacing) * 100);
} }
.max-w-lg {
max-width: var(--container-lg);
}
.max-w-md { .max-w-md {
max-width: var(--container-md); max-width: var(--container-md);
} }
@@ -537,6 +579,9 @@
.max-w-screen-lg { .max-w-screen-lg {
max-width: var(--breakpoint-lg); max-width: var(--breakpoint-lg);
} }
.max-w-screen-md {
max-width: var(--breakpoint-md);
}
.max-w-screen-xl { .max-w-screen-xl {
max-width: var(--breakpoint-xl); max-width: var(--breakpoint-xl);
} }
@@ -588,6 +633,9 @@
.cursor-grab { .cursor-grab {
cursor: grab; cursor: grab;
} }
.cursor-help {
cursor: help;
}
.cursor-not-allowed { .cursor-not-allowed {
cursor: not-allowed; cursor: not-allowed;
} }
@@ -600,12 +648,24 @@
.resize-none { .resize-none {
resize: none; resize: none;
} }
.list-inside {
list-style-position: inside;
}
.list-disc {
list-style-type: disc;
}
.appearance-none {
appearance: none;
}
.grid-cols-1 { .grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
} }
.grid-cols-2 { .grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-cols-7 { .grid-cols-7 {
grid-template-columns: repeat(7, minmax(0, 1fr)); grid-template-columns: repeat(7, minmax(0, 1fr));
} }
@@ -621,6 +681,9 @@
.items-center { .items-center {
align-items: center; align-items: center;
} }
.items-end {
align-items: flex-end;
}
.items-start { .items-start {
align-items: flex-start; align-items: flex-start;
} }
@@ -636,6 +699,9 @@
.gap-1 { .gap-1 {
gap: calc(var(--spacing) * 1); gap: calc(var(--spacing) * 1);
} }
.gap-1\.5 {
gap: calc(var(--spacing) * 1.5);
}
.gap-2 { .gap-2 {
gap: calc(var(--spacing) * 2); gap: calc(var(--spacing) * 2);
} }
@@ -651,6 +717,13 @@
.gap-8 { .gap-8 {
gap: calc(var(--spacing) * 8); gap: calc(var(--spacing) * 8);
} }
.space-y-0\.5 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 0.5) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 0.5) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-1 { .space-y-1 {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-space-y-reverse: 0; --tw-space-y-reverse: 0;
@@ -722,6 +795,14 @@
.gap-y-5 { .gap-y-5 {
row-gap: calc(var(--spacing) * 5); row-gap: calc(var(--spacing) * 5);
} }
.divide-x {
: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)));
}
}
.divide-y { .divide-y {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-divide-y-reverse: 0; --tw-divide-y-reverse: 0;
@@ -801,6 +882,12 @@
.border-blue { .border-blue {
border-color: var(--blue); border-color: var(--blue);
} }
.border-blue\/30 {
border-color: var(--blue);
@supports (color: color-mix(in lab, red, red)) {
border-color: color-mix(in oklab, var(--blue) 30%, transparent);
}
}
.border-blue\/50 { .border-blue\/50 {
border-color: var(--blue); border-color: var(--blue);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -822,6 +909,9 @@
border-color: color-mix(in oklab, var(--red) 30%, transparent); border-color: color-mix(in oklab, var(--red) 30%, transparent);
} }
} }
.border-surface0 {
border-color: var(--surface0);
}
.border-surface1 { .border-surface1 {
border-color: var(--surface1); border-color: var(--surface1);
} }
@@ -858,6 +948,12 @@
.bg-blue { .bg-blue {
background-color: var(--blue); background-color: var(--blue);
} }
.bg-blue\/5 {
background-color: var(--blue);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--blue) 5%, transparent);
}
}
.bg-blue\/20 { .bg-blue\/20 {
background-color: var(--blue); background-color: var(--blue);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -891,6 +987,12 @@
background-color: color-mix(in oklab, var(--green) 20%, transparent); background-color: color-mix(in oklab, var(--green) 20%, transparent);
} }
} }
.bg-green\/40 {
background-color: var(--green);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--green) 40%, transparent);
}
}
.bg-mantle { .bg-mantle {
background-color: var(--mantle); background-color: var(--mantle);
} }
@@ -900,6 +1002,12 @@
.bg-peach { .bg-peach {
background-color: var(--peach); background-color: var(--peach);
} }
.bg-peach\/20 {
background-color: var(--peach);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--peach) 20%, transparent);
}
}
.bg-red { .bg-red {
background-color: var(--red); background-color: var(--red);
} }
@@ -960,6 +1068,9 @@
.p-2\.5 { .p-2\.5 {
padding: calc(var(--spacing) * 2.5); padding: calc(var(--spacing) * 2.5);
} }
.p-3 {
padding: calc(var(--spacing) * 3);
}
.p-4 { .p-4 {
padding: calc(var(--spacing) * 4); padding: calc(var(--spacing) * 4);
} }
@@ -969,9 +1080,15 @@
.p-8 { .p-8 {
padding: calc(var(--spacing) * 8); padding: calc(var(--spacing) * 8);
} }
.px-1\.5 {
padding-inline: calc(var(--spacing) * 1.5);
}
.px-2 { .px-2 {
padding-inline: calc(var(--spacing) * 2); padding-inline: calc(var(--spacing) * 2);
} }
.px-2\.5 {
padding-inline: calc(var(--spacing) * 2.5);
}
.px-3 { .px-3 {
padding-inline: calc(var(--spacing) * 3); padding-inline: calc(var(--spacing) * 3);
} }
@@ -990,6 +1107,9 @@
.py-1 { .py-1 {
padding-block: calc(var(--spacing) * 1); padding-block: calc(var(--spacing) * 1);
} }
.py-1\.5 {
padding-block: calc(var(--spacing) * 1.5);
}
.py-2 { .py-2 {
padding-block: calc(var(--spacing) * 2); padding-block: calc(var(--spacing) * 2);
} }
@@ -1008,6 +1128,9 @@
.pt-2 { .pt-2 {
padding-top: calc(var(--spacing) * 2); padding-top: calc(var(--spacing) * 2);
} }
.pt-3 {
padding-top: calc(var(--spacing) * 3);
}
.pt-4 { .pt-4 {
padding-top: calc(var(--spacing) * 4); padding-top: calc(var(--spacing) * 4);
} }
@@ -1083,6 +1206,10 @@
--tw-leading: calc(var(--spacing) * 6); --tw-leading: calc(var(--spacing) * 6);
line-height: calc(var(--spacing) * 6); line-height: calc(var(--spacing) * 6);
} }
.leading-none {
--tw-leading: 1;
line-height: 1;
}
.leading-relaxed { .leading-relaxed {
--tw-leading: var(--leading-relaxed); --tw-leading: var(--leading-relaxed);
line-height: var(--leading-relaxed); line-height: var(--leading-relaxed);
@@ -1091,6 +1218,10 @@
--tw-font-weight: var(--font-weight-bold); --tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
} }
.font-light {
--tw-font-weight: var(--font-weight-light);
font-weight: var(--font-weight-light);
}
.font-medium { .font-medium {
--tw-font-weight: var(--font-weight-medium); --tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
@@ -1141,21 +1272,51 @@
.text-overlay0 { .text-overlay0 {
color: var(--overlay0); color: var(--overlay0);
} }
.text-peach {
color: var(--peach);
}
.text-red { .text-red {
color: var(--red); color: var(--red);
} }
.text-red\/60 {
color: var(--red);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--red) 60%, transparent);
}
}
.text-red\/80 {
color: var(--red);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--red) 80%, transparent);
}
}
.text-subtext0 { .text-subtext0 {
color: var(--subtext0); color: var(--subtext0);
} }
.text-subtext1 { .text-subtext1 {
color: var(--subtext1); color: var(--subtext1);
} }
.text-teal {
color: var(--teal);
}
.text-text { .text-text {
color: var(--text); color: var(--text);
} }
.text-yellow { .text-yellow {
color: var(--yellow); color: var(--yellow);
} }
.text-yellow\/60 {
color: var(--yellow);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--yellow) 60%, transparent);
}
}
.text-yellow\/70 {
color: var(--yellow);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--yellow) 70%, transparent);
}
}
.text-yellow\/80 { .text-yellow\/80 {
color: var(--yellow); color: var(--yellow);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -1174,6 +1335,11 @@
.italic { .italic {
font-style: italic; font-style: italic;
} }
.placeholder-subtext0 {
&::placeholder {
color: var(--subtext0);
}
}
.opacity-0 { .opacity-0 {
opacity: 0%; opacity: 0%;
} }
@@ -1187,6 +1353,10 @@
--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
} }
.shadow-md {
--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-sm { .shadow-sm {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -1250,6 +1420,83 @@
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
} }
.group-hover\:visible {
&:is(:where(.group):hover *) {
@media (hover: hover) {
visibility: visible;
}
}
}
.group-hover\:opacity-100 {
&:is(:where(.group):hover *) {
@media (hover: hover) {
opacity: 100%;
}
}
}
.file\:mr-4 {
&::file-selector-button {
margin-right: calc(var(--spacing) * 4);
}
}
.file\:rounded {
&::file-selector-button {
border-radius: 0.25rem;
}
}
.file\:border-0 {
&::file-selector-button {
border-style: var(--tw-border-style);
border-width: 0px;
}
}
.file\:bg-blue {
&::file-selector-button {
background-color: var(--blue);
}
}
.file\:px-3 {
&::file-selector-button {
padding-inline: calc(var(--spacing) * 3);
}
}
.file\:py-1 {
&::file-selector-button {
padding-block: calc(var(--spacing) * 1);
}
}
.file\:text-sm {
&::file-selector-button {
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
}
}
.file\:font-medium {
&::file-selector-button {
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
}
}
.file\:text-mantle {
&::file-selector-button {
color: var(--mantle);
}
}
.file\:transition {
&::file-selector-button {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
}
.hover\:-translate-y-0\.5 {
&:hover {
@media (hover: hover) {
--tw-translate-y: calc(var(--spacing) * -0.5);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
}
}
.hover\:cursor-pointer { .hover\:cursor-pointer {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1325,6 +1572,26 @@
} }
} }
} }
.hover\:bg-peach\/75 {
&:hover {
@media (hover: hover) {
background-color: var(--peach);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--peach) 75%, transparent);
}
}
}
}
.hover\:bg-peach\/80 {
&:hover {
@media (hover: hover) {
background-color: var(--peach);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--peach) 80%, transparent);
}
}
}
}
.hover\:bg-red\/25 { .hover\:bg-red\/25 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1335,6 +1602,16 @@
} }
} }
} }
.hover\:bg-red\/40 {
&:hover {
@media (hover: hover) {
background-color: var(--red);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--red) 40%, transparent);
}
}
}
}
.hover\:bg-red\/75 { .hover\:bg-red\/75 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1420,6 +1697,16 @@
} }
} }
} }
.hover\:bg-yellow\/80 {
&:hover {
@media (hover: hover) {
background-color: var(--yellow);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--yellow) 80%, transparent);
}
}
}
}
.hover\:text-blue { .hover\:text-blue {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1495,6 +1782,35 @@
} }
} }
} }
.hover\:shadow-lg {
&:hover {
@media (hover: hover) {
--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
}
.file\:hover\:cursor-pointer {
&::file-selector-button {
&:hover {
@media (hover: hover) {
cursor: pointer;
}
}
}
}
.file\:hover\:bg-blue\/80 {
&::file-selector-button {
&:hover {
@media (hover: hover) {
background-color: var(--blue);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--blue) 80%, transparent);
}
}
}
}
}
.focus\:border-blue { .focus\:border-blue {
&:focus { &:focus {
border-color: var(--blue); border-color: var(--blue);
@@ -1576,11 +1892,25 @@
} }
} }
} }
.disabled\:bg-peach\/40 {
&:disabled {
background-color: var(--peach);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--peach) 40%, transparent);
}
}
}
.disabled\:opacity-50 { .disabled\:opacity-50 {
&:disabled { &:disabled {
opacity: 50%; opacity: 50%;
} }
} }
.disabled\:shadow-none {
&:disabled {
--tw-shadow: 0 0 #0000;
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.sm\:end-6 { .sm\:end-6 {
@media (width >= 40rem) { @media (width >= 40rem) {
inset-inline-end: calc(var(--spacing) * 6); inset-inline-end: calc(var(--spacing) * 6);
@@ -1668,6 +1998,11 @@
scale: var(--tw-scale-x) var(--tw-scale-y); scale: var(--tw-scale-x) var(--tw-scale-y);
} }
} }
.sm\:grid-cols-4 {
@media (width >= 40rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.sm\:flex-row { .sm\:flex-row {
@media (width >= 40rem) { @media (width >= 40rem) {
flex-direction: row; flex-direction: row;
@@ -1789,11 +2124,21 @@
display: inline; display: inline;
} }
} }
.lg\:grid-cols-2 {
@media (width >= 64rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.lg\:grid-cols-3 { .lg\:grid-cols-3 {
@media (width >= 64rem) { @media (width >= 64rem) {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
} }
.lg\:grid-cols-6 {
@media (width >= 64rem) {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
.lg\:items-end { .lg\:items-end {
@media (width >= 64rem) { @media (width >= 64rem) {
align-items: flex-end; align-items: flex-end;
@@ -2029,7 +2374,7 @@
inherits: false; inherits: false;
initial-value: 0; initial-value: 0;
} }
@property --tw-divide-y-reverse { @property --tw-divide-x-reverse {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
initial-value: 0; initial-value: 0;
@@ -2039,6 +2384,11 @@
inherits: false; inherits: false;
initial-value: solid; initial-value: solid;
} }
@property --tw-divide-y-reverse {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-leading { @property --tw-leading {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
@@ -2198,8 +2548,9 @@
--tw-skew-y: initial; --tw-skew-y: initial;
--tw-space-y-reverse: 0; --tw-space-y-reverse: 0;
--tw-space-x-reverse: 0; --tw-space-x-reverse: 0;
--tw-divide-y-reverse: 0; --tw-divide-x-reverse: 0;
--tw-border-style: solid; --tw-border-style: solid;
--tw-divide-y-reverse: 0;
--tw-leading: initial; --tw-leading: initial;
--tw-font-weight: initial; --tw-font-weight: initial;
--tw-tracking: initial; --tw-tracking: initial;

View File

@@ -0,0 +1,84 @@
// localtime.js - Converts UTC <time> elements to the user's local timezone.
//
// Usage: <time datetime="2026-01-14T10:30:00Z" data-localtime="datetime">fallback</time>
//
// Supported data-localtime values:
// "date" → "Mon 2 Jan 2026"
// "time" → "3:04 PM"
// "datetime" → "Mon 2 Jan 2026 at 3:04 PM"
// "short" → "Mon 2 Jan 3:04 PM"
// "histdate" → "2 Jan 2006 15:04"
(function () {
const SHORT_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const SHORT_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function formatTime12(d) {
let h = d.getHours();
const m = pad(d.getMinutes());
const ampm = h >= 12 ? 'PM' : 'AM';
h = h % 12 || 12;
return h + ':' + m + ' ' + ampm;
}
function formatDate(d) {
return SHORT_DAYS[d.getDay()] + ' ' + d.getDate() + ' ' +
SHORT_MONTHS[d.getMonth()] + ' ' + d.getFullYear();
}
function formatLocalTime(el) {
const iso = el.getAttribute('datetime');
if (!iso) return;
const d = new Date(iso);
if (isNaN(d.getTime())) return;
const fmt = el.getAttribute('data-localtime');
let text;
switch (fmt) {
case 'date':
text = formatDate(d);
break;
case 'time':
text = formatTime12(d);
break;
case 'datetime':
text = formatDate(d) + ' at ' + formatTime12(d);
break;
case 'short':
text = SHORT_DAYS[d.getDay()] + ' ' + d.getDate() + ' ' +
SHORT_MONTHS[d.getMonth()] + ' ' + formatTime12(d);
break;
case 'histdate':
text = d.getDate() + ' ' + SHORT_MONTHS[d.getMonth()] + ' ' +
d.getFullYear() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
break;
default:
text = formatDate(d) + ' at ' + formatTime12(d);
}
el.textContent = text;
// Add timezone tooltip so users know the displayed time is in their local timezone
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
el.title = 'Displayed in your local timezone (' + tz + ')';
}
function processAll(root) {
const els = (root || document).querySelectorAll('time[data-localtime]');
els.forEach(formatLocalTime);
}
// Process on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { processAll(); });
} else {
processAll();
}
// Re-process after HTMX swaps
document.addEventListener('htmx:afterSettle', function (evt) {
processAll(evt.detail.elt);
});
})();

File diff suppressed because one or more lines are too long

View File

@@ -84,7 +84,7 @@ func AdminRoleCreate(s *hws.Server, conn *db.DB) http.Handler {
CreatedAt: time.Now().Unix(), CreatedAt: time.Now().Unix(),
} }
err := db.CreateRole(ctx, tx, newRole, db.NewAudit(r, nil)) err := db.CreateRole(ctx, tx, newRole, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.CreateRole") return false, errors.Wrap(err, "db.CreateRole")
} }
@@ -196,7 +196,7 @@ func AdminRoleDelete(s *hws.Server, conn *db.DB) http.Handler {
} }
// Delete the role with audit logging // Delete the role with audit logging
err = db.DeleteRole(ctx, tx, roleID, db.NewAudit(r, nil)) err = db.DeleteRole(ctx, tx, roleID, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
respond.NotFound(w, err) respond.NotFound(w, err)
@@ -320,7 +320,7 @@ func AdminRolePermissionsUpdate(s *hws.Server, conn *db.DB) http.Handler {
} }
return false, errors.Wrap(err, "db.GetRoleByID") return false, errors.Wrap(err, "db.GetRoleByID")
} }
err = role.UpdatePermissions(ctx, tx, permissionIDs, db.NewAudit(r, nil)) err = role.UpdatePermissions(ctx, tx, permissionIDs, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
return false, errors.Wrap(err, "role.UpdatePermissions") return false, errors.Wrap(err, "role.UpdatePermissions")
} }

View File

@@ -0,0 +1,501 @@
package handlers
import (
"context"
"net/http"
"strconv"
"time"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/contexts"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/permissions"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/validation"
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"git.haelnorr.com/h/timefmt"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// FixtureDetailPage renders the fixture detail page with scheduling UI, history,
// result display, and team rosters
func FixtureDetailPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
activeTab := r.URL.Query().Get("tab")
if activeTab == "" {
activeTab = "overview"
}
var fixture *db.Fixture
var currentSchedule *db.FixtureSchedule
var history []*db.FixtureSchedule
var canSchedule bool
var userTeamID int
var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus
var nominatedFreeAgents []*db.FixtureFreeAgent
var availableFreeAgents []*db.SeasonLeagueFreeAgent
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
currentSchedule, err = db.GetCurrentFixtureSchedule(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetCurrentFixtureSchedule")
}
history, err = db.GetFixtureScheduleHistory(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureScheduleHistory")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err = fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
// Fetch fixture result if it exists
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
// Fetch team rosters with play status
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
// Fetch free agent nominations for this fixture
nominatedFreeAgents, err = db.GetNominatedFreeAgents(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
}
// Fetch available free agents for nomination (if user can schedule or manage fixtures)
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
if canSchedule || canManage {
availableFreeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
}
}
return true, nil
}); !ok {
return
}
renderSafely(seasonsview.FixtureDetailPage(
fixture, currentSchedule, history, canSchedule, userTeamID,
result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents,
), s, r, w)
})
}
// ProposeSchedule handles POST /fixtures/{fixture_id}/schedule
func ProposeSchedule(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
DayNumeric2().T().Hour24().Colon().Minute().Build()
aest, _ := time.LoadLocation("Australia/Sydney")
// scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value
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) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to propose a schedule", nil)
return false, nil
}
_, err = db.ProposeFixtureSchedule(ctx, tx, fixtureID, userTeamID, scheduledTime, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Propose", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.ProposeFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.SuccessWithDelay(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// AcceptSchedule handles POST /fixtures/{fixture_id}/schedule/{schedule_id}/accept
func AcceptSchedule(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to accept a schedule", nil)
return false, nil
}
err = db.AcceptFixtureSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Accept", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.AcceptFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Schedule Accepted", "The fixture time has been confirmed.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// RejectSchedule handles POST /fixtures/{fixture_id}/schedule/{schedule_id}/reject
func RejectSchedule(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, _, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to reject a schedule", nil)
return false, nil
}
err = db.RejectFixtureSchedule(ctx, tx, scheduleID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Reject", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.RejectFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Schedule Rejected", "The proposed time has been rejected.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// PostponeSchedule handles POST /fixtures/{fixture_id}/schedule/postpone
func PostponeSchedule(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, _, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to postpone a fixture", nil)
return false, nil
}
err = db.PostponeFixtureSchedule(ctx, tx, fixtureID, reason, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Postpone", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.PostponeFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Fixture Postponed", "The fixture has been postponed.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// RescheduleFixture handles POST /fixtures/{fixture_id}/schedule/reschedule
func RescheduleFixture(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
DayNumeric2().T().Hour24().Colon().Minute().Build()
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) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to reschedule a fixture", nil)
return false, nil
}
_, err = db.RescheduleFixtureSchedule(ctx, tx, fixtureID, userTeamID, scheduledTime, reason, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Reschedule", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.RescheduleFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Fixture Rescheduled", "The new proposed time has been submitted.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// WithdrawSchedule handles POST /fixtures/{fixture_id}/schedule/{schedule_id}/withdraw
// Only the proposing team manager can withdraw their own pending proposal.
func WithdrawSchedule(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to withdraw a proposal", nil)
return false, nil
}
err = db.WithdrawFixtureSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Withdraw", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.WithdrawFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Proposal Withdrawn", "Your proposed time has been withdrawn.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// CancelSchedule handles POST /fixtures/{fixture_id}/schedule/cancel
// This is a moderator-only action that requires fixtures.manage permission.
func CancelSchedule(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
err := db.CancelFixtureSchedule(ctx, tx, fixtureID, reason, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Cancel", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.CancelFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Forfeit Declared", "The fixture has been declared a forfeit.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}

View File

@@ -0,0 +1,471 @@
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"
)
const maxUploadSize = 10 << 20 // 10 MB
// UploadMatchLogsPage renders the upload form for match log files
func UploadMatchLogsPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
// Check if result already exists
existing, err := db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
if existing != nil {
throw.BadRequest(s, w, r, "A result already exists for this fixture. Discard it first to re-upload.", nil)
return false, nil
}
return true, nil
}); !ok {
return
}
renderSafely(seasonsview.FixtureUploadResultPage(fixture), s, r, w)
})
}
// UploadMatchLogs handles POST /fixtures/{fixture_id}/results/upload
// Parses 3 multipart files, validates, detects tampering, and stores results.
func UploadMatchLogs(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
// Parse multipart form
err = r.ParseMultipartForm(maxUploadSize)
if err != nil {
notify.Warn(s, w, r, "Upload Failed", "Could not parse uploaded files. Ensure files are under 10MB.", nil)
return
}
// Read the 3 period files
periodNames := []string{"period_1", "period_2", "period_3"}
logs := make([]*slapshotapi.MatchLog, 3)
for i, name := range periodNames {
file, _, err := r.FormFile(name)
if err != nil {
notify.Warn(s, w, r, "Missing File", "All 3 period files are required. Missing: "+name, 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: "+name, nil)
return
}
log, err := slapshotapi.ParseMatchLog(data)
if err != nil {
notify.Warn(s, w, r, "Parse Error", "Could not parse "+name+": "+err.Error(), nil)
return
}
logs[i] = log
}
// Detect tampering
tamperingDetected, tamperingReason, err := slapshotapi.DetectTampering(logs)
if err != nil {
notify.Warn(s, w, r, "Validation Error", "Tampering check failed: "+err.Error(), nil)
return
}
var result *db.FixtureResult
var unmappedPlayers []string
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
// Check if result already exists
existing, err := db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
if existing != nil {
notify.Warn(s, w, r, "Result Exists", "A result already exists for this fixture. Discard it first to re-upload.", nil)
return false, nil
}
// Collect all unique game_user_ids across all periods
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 game_user_ids to players
playerLookup, err := MapGameUserIDsToPlayers(ctx, tx, gameUserIDs, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return false, errors.Wrap(err, "MapGameUserIDsToPlayers")
}
// Determine team orientation using all players from all periods
allPlayers := []slapshotapi.Player{}
// Use period 3 players for orientation (most complete)
allPlayers = append(allPlayers, logs[2].Players...)
fixtureHomeIsLogsHome, unmapped, err := DetermineTeamOrientation(ctx, tx, fixture, allPlayers, playerLookup)
if err != nil {
notify.Warn(s, w, r, "Orientation Error",
"Could not determine team orientation: "+err.Error()+". Please ensure players have registered Slapshot IDs.", nil)
return false, nil
}
unmappedPlayers = unmapped
// Use period 3 (final) data for the result
finalLog := logs[2]
// Determine winner in fixture terms
winner := finalLog.Winner
homeScore := finalLog.Score.Home
awayScore := finalLog.Score.Away
if !fixtureHomeIsLogsHome {
// Logs are reversed - swap
switch winner {
case "home":
winner = "away"
case "away":
winner = "home"
}
homeScore, awayScore = awayScore, homeScore
}
// Parse metadata
periodsEnabled := finalLog.PeriodsEnabled == "True"
customMercyRule, _ := strconv.Atoi(finalLog.CustomMercyRule)
matchLength, _ := strconv.Atoi(finalLog.MatchLength)
user := db.CurrentUser(ctx)
// Build result
var tamperingReasonPtr *string
if tamperingDetected {
tamperingReasonPtr = &tamperingReason
}
result = &db.FixtureResult{
FixtureID: fixtureID,
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 for all 3 periods
playerStats := []*db.FixtureResultPlayerStats{}
for periodIdx, log := range logs {
periodNum := periodIdx + 1
for _, p := range log.Players {
// Determine team in fixture terms
team := p.Team
if !fixtureHomeIsLogsHome {
if team == "home" {
team = "away"
} else {
team = "home"
}
}
// Look up player
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,
// Convert float stats to int
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)
}
}
// Check each player stat: if the player is a registered free agent, mark them
for _, ps := range playerStats {
if ps.PlayerID == nil {
continue
}
// Check if the player is a registered free agent
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 and stats
result, err = db.InsertFixtureResult(ctx, tx, result, playerStats, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "db.InsertFixtureResult")
}
return true, nil
}); !ok {
return
}
_ = unmappedPlayers // stored for review page redirect
respond.HXRedirect(w, "/fixtures/%d/results/review", fixtureID)
})
}
// ReviewMatchResult handles GET /fixtures/{fixture_id}/results/review
func ReviewMatchResult(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
var result *db.FixtureResult
var unmappedPlayers []string
var faWarnings []seasonsview.FreeAgentWarning
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
result, err = db.GetPendingFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetPendingFixtureResult")
}
if result == nil {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
// Get nominated free agents for this fixture
nominatedFAs, err := db.GetNominatedFreeAgents(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
}
// Map player ID to the side ("home"/"away") that nominated them
nominatedFASide := map[int]string{}
for _, nfa := range nominatedFAs {
if nfa.TeamID == fixture.HomeTeamID {
nominatedFASide[nfa.PlayerID] = "home"
} else {
nominatedFASide[nfa.PlayerID] = "away"
}
}
// Helper to resolve side to team name
teamNameForSide := func(side string) string {
if side == "home" {
return fixture.HomeTeam.Name
}
return fixture.AwayTeam.Name
}
// Build unmapped players and free agent warnings from stats
seen := map[int]bool{}
for _, ps := range result.PlayerStats {
if ps.PeriodNum != 3 {
continue
}
if ps.PlayerID == nil {
unmappedPlayers = append(unmappedPlayers,
ps.PlayerGameUserID+" ("+ps.PlayerUsername+")")
} else if ps.IsFreeAgent && !seen[*ps.PlayerID] {
seen[*ps.PlayerID] = true
nominatedSide, wasNominated := nominatedFASide[*ps.PlayerID]
if !wasNominated {
faWarnings = append(faWarnings, seasonsview.FreeAgentWarning{
Name: ps.PlayerUsername,
Reason: "not nominated for this fixture",
})
} else if nominatedSide != ps.Team {
faWarnings = append(faWarnings, seasonsview.FreeAgentWarning{
Name: ps.PlayerUsername,
Reason: "nominated by " + teamNameForSide(nominatedSide) + ", but played for " + teamNameForSide(ps.Team),
})
}
}
}
return true, nil
}); !ok {
return
}
renderSafely(seasonsview.FixtureReviewResultPage(fixture, result, unmappedPlayers, faWarnings), s, r, w)
})
}
// FinalizeMatchResult handles POST /fixtures/{fixture_id}/results/finalize
func FinalizeMatchResult(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
err := db.FinalizeFixtureResult(ctx, tx, fixtureID, 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.FinalizeFixtureResult")
}
return true, nil
}); !ok {
return
}
notify.SuccessWithDelay(s, w, r, "Result Finalized", "The match result has been finalized.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// DiscardMatchResult handles POST /fixtures/{fixture_id}/results/discard
func DiscardMatchResult(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
err := db.DeleteFixtureResult(ctx, tx, fixtureID, 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.DeleteFixtureResult")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Result Discarded", "The match result has been discarded. You can upload new logs.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}

View File

@@ -0,0 +1,187 @@
package handlers
import (
"context"
"fmt"
"math"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// PlayerLookupResult stores the resolved player info from a game_user_id lookup
type PlayerLookupResult struct {
Player *db.Player
TeamID int
Found bool
Unmapped bool // true if player not in system (potential free agent)
}
// MapGameUserIDsToPlayers creates a lookup map from game_user_id to resolved player info.
// It looks up players by their SlapID (which corresponds to game_user_id in match logs)
// and checks their team assignment in the given season/league.
func MapGameUserIDsToPlayers(
ctx context.Context,
tx bun.Tx,
gameUserIDs []string,
seasonID, leagueID int,
) (map[string]*PlayerLookupResult, error) {
result := make(map[string]*PlayerLookupResult, len(gameUserIDs))
// Initialize all as unmapped
for _, id := range gameUserIDs {
result[id] = &PlayerLookupResult{Unmapped: true}
}
if len(gameUserIDs) == 0 {
return result, nil
}
// Get all players that have a slap_id matching any of the game_user_ids
// game_user_id in logs is a string representation of the slapshot player ID (uint32)
players := []*db.Player{}
err := tx.NewSelect().
Model(&players).
Where("p.slap_id::text IN (?)", bun.In(gameUserIDs)).
Relation("User").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect players")
}
// Build a map of slapID -> player
slapIDToPlayer := make(map[string]*db.Player, len(players))
playerIDs := make([]int, 0, len(players))
for _, p := range players {
if p.SlapID != nil {
key := slapIDStr(*p.SlapID)
slapIDToPlayer[key] = p
playerIDs = append(playerIDs, p.ID)
}
}
// Get team roster entries for these players in the given season/league
rosters := []*db.TeamRoster{}
if len(playerIDs) > 0 {
err = tx.NewSelect().
Model(&rosters).
Where("tr.season_id = ?", seasonID).
Where("tr.league_id = ?", leagueID).
Where("tr.player_id IN (?)", bun.In(playerIDs)).
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect rosters")
}
}
// Build playerID -> teamID map
playerTeam := make(map[int]int, len(rosters))
for _, r := range rosters {
playerTeam[r.PlayerID] = r.TeamID
}
// Populate results
for _, id := range gameUserIDs {
player, found := slapIDToPlayer[id]
if !found {
continue // stays unmapped
}
teamID, onTeam := playerTeam[player.ID]
result[id] = &PlayerLookupResult{
Player: player,
TeamID: teamID,
Found: true,
Unmapped: !onTeam,
}
}
return result, nil
}
// DetermineTeamOrientation validates that logs match fixture's team assignment
// by cross-checking player game_user_ids against registered rosters.
//
// Returns:
// - fixtureHomeIsLogsHome: true if fixture's home team maps to "home" in logs
// - unmappedPlayers: list of game_user_ids that couldn't be resolved
// - error: if orientation cannot be determined
func DetermineTeamOrientation(
ctx context.Context,
tx bun.Tx,
fixture *db.Fixture,
allPlayers []slapshotapi.Player,
playerLookup map[string]*PlayerLookupResult,
) (bool, []string, error) {
if fixture == nil {
return false, nil, errors.New("fixture cannot be nil")
}
unmapped := []string{}
// Count how many fixture-home-team players are on "home" vs "away" in logs
homeTeamOnHome := 0 // fixture home team players that are "home" in logs
homeTeamOnAway := 0 // fixture home team players that are "away" in logs
awayTeamOnHome := 0 // fixture away team players that are "home" in logs
awayTeamOnAway := 0 // fixture away team players that are "away" in logs
for _, p := range allPlayers {
lookup, exists := playerLookup[p.GameUserID]
if !exists || lookup.Unmapped {
unmapped = append(unmapped, p.GameUserID+" ("+p.Username+")")
continue
}
logTeam := p.Team // "home" or "away" in the log
switch lookup.TeamID {
case fixture.HomeTeamID:
if logTeam == "home" {
homeTeamOnHome++
} else {
homeTeamOnAway++
}
case fixture.AwayTeamID:
if logTeam == "home" {
awayTeamOnHome++
} else {
awayTeamOnAway++
}
default:
// Player is on a team but not one of the fixture teams
unmapped = append(unmapped, p.GameUserID+" ("+p.Username+")")
}
}
totalMapped := homeTeamOnHome + homeTeamOnAway + awayTeamOnHome + awayTeamOnAway
if totalMapped == 0 {
return false, unmapped, errors.New("no mapped players found, cannot determine team orientation")
}
// Calculate orientation: how many agree with "home=home" vs "home=away"
matchOrientation := homeTeamOnHome + awayTeamOnAway // logs match fixture orientation
reverseOrientation := homeTeamOnAway + awayTeamOnHome // logs are reversed
if matchOrientation == reverseOrientation {
return false, unmapped, errors.New("cannot determine team orientation: equal evidence for both orientations")
}
fixtureHomeIsLogsHome := matchOrientation > reverseOrientation
return fixtureHomeIsLogsHome, unmapped, nil
}
// FloatToIntPtr converts a *float64 to *int by truncating the decimal.
// Returns nil if input is nil.
func FloatToIntPtr(f *float64) *int {
if f == nil {
return nil
}
v := int(math.Round(*f))
return &v
}
// slapIDStr converts a uint32 SlapID to a string for map lookups
func slapIDStr(id uint32) string {
return fmt.Sprintf("%d", id)
}

View File

@@ -31,11 +31,10 @@ func GenerateFixtures(
return return
} }
var season *db.Season var sl *db.SeasonLeague
var league *db.League
var fixtures []*db.Fixture var fixtures []*db.Fixture
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
_, err := db.NewRound(ctx, tx, seasonShortName, leagueShortName, round, db.NewAudit(r, nil)) _, err := db.NewRound(ctx, tx, seasonShortName, leagueShortName, round, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.NewRound")) respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))
@@ -43,7 +42,7 @@ func GenerateFixtures(
} }
return false, errors.Wrap(err, "db.NewRound") return false, errors.Wrap(err, "db.NewRound")
} }
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName) sl, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.GetFixtures") return false, errors.Wrap(err, "db.GetFixtures")
} }
@@ -52,7 +51,7 @@ func GenerateFixtures(
return return
} }
renderSafely(seasonsview.SeasonLeagueManageFixtures(season, league, fixtures), s, r, w) renderSafely(seasonsview.SeasonLeagueManageFixtures(sl.Season, sl.League, fixtures), s, r, w)
}) })
} }
@@ -69,6 +68,7 @@ func UpdateFixtures(
leagueShortName := getter.String("league_short_name").TrimSpace().Required().Value leagueShortName := getter.String("league_short_name").TrimSpace().Required().Value
allocations := getter.GetMaps("allocations") allocations := getter.GetMaps("allocations")
if !getter.ValidateAndNotify(s, w, r) { if !getter.ValidateAndNotify(s, w, r) {
w.WriteHeader(http.StatusBadRequest)
return return
} }
updates, err := mapUpdates(allocations) updates, err := mapUpdates(allocations)
@@ -81,7 +81,7 @@ func UpdateFixtures(
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
_, _, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName) _, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.NewRound")) respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))
@@ -95,7 +95,7 @@ func UpdateFixtures(
notify.Warn(s, w, r, "Invalid game weeks", "A game week is missing or has no games", nil) notify.Warn(s, w, r, "Invalid game weeks", "A game week is missing or has no games", nil)
return false, nil return false, nil
} }
err = db.UpdateFixtureGameWeeks(ctx, tx, fixtures, db.NewAudit(r, nil)) err = db.UpdateFixtureGameWeeks(ctx, tx, fixtures, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.UpdateFixtureGameWeeks")) respond.BadRequest(w, errors.Wrap(err, "db.UpdateFixtureGameWeeks"))
@@ -122,7 +122,7 @@ func DeleteFixture(
return return
} }
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
err := db.DeleteFixture(ctx, tx, fixtureID, db.NewAudit(r, nil)) err := db.DeleteFixture(ctx, tx, fixtureID, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.DeleteFixture")) respond.NotFound(w, errors.Wrap(err, "db.DeleteFixture"))
@@ -158,11 +158,28 @@ func updateFixtures(fixtures []*db.Fixture, updates map[int]int) ([]*db.Fixture,
gameWeeks := map[int]int{} gameWeeks := map[int]int{}
for _, fixture := range fixtures { for _, fixture := range fixtures {
if gameWeek, exists := updates[fixture.ID]; exists { if gameWeek, exists := updates[fixture.ID]; exists {
fixture.GameWeek = &gameWeek var newValue *int
var oldValue int
if fixture.GameWeek != nil {
oldValue = *fixture.GameWeek
} else {
oldValue = 0
}
if gameWeek == 0 {
newValue = nil
} else {
newValue = &gameWeek
}
if gameWeek != oldValue {
fixture.GameWeek = newValue
updated = append(updated, fixture) updated = append(updated, fixture)
} }
// fuck i hate pointers sometimes
}
if fixture.GameWeek != nil {
gameWeeks[*fixture.GameWeek]++ gameWeeks[*fixture.GameWeek]++
} }
}
for i := range len(gameWeeks) { for i := range len(gameWeeks) {
count, exists := gameWeeks[i+1] count, exists := gameWeeks[i+1]
if !exists || count < 1 { if !exists || count < 1 {

View File

@@ -0,0 +1,356 @@
package handlers
import (
"context"
"net/http"
"strconv"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/contexts"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/permissions"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/validation"
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// FreeAgentsListPage renders the free agents tab of a season league page
func FreeAgentsListPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name")
var season *db.Season
var league *db.League
var freeAgents []*db.SeasonLeagueFreeAgent
var availablePlayers []*db.Player
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetSeasonLeague")
}
season = sl.Season
league = sl.League
freeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, season.ID, league.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
}
availablePlayers, err = db.GetPlayersNotOnTeam(ctx, tx, season.ID, league.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
}
// Filter out players already registered as free agents
faMap := make(map[int]bool, len(freeAgents))
for _, fa := range freeAgents {
faMap[fa.PlayerID] = true
}
filtered := make([]*db.Player, 0, len(availablePlayers))
for _, p := range availablePlayers {
if !faMap[p.ID] {
filtered = append(filtered, p)
}
}
availablePlayers = filtered
return true, nil
}); !ok {
return
}
if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueFreeAgentsPage(season, league, freeAgents, availablePlayers), s, r, w)
} else {
renderSafely(seasonsview.SeasonLeagueFreeAgents(season, league, freeAgents, availablePlayers), s, r, w)
}
})
}
// RegisterFreeAgent handles POST to register a player as a free agent
func RegisterFreeAgent(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name")
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
respond.BadRequest(w, errors.New("failed to parse form"))
return
}
playerID := getter.Int("player_id").Required().Value
if !getter.ValidateAndNotify(s, w, r) {
respond.BadRequest(w, errors.New("invalid form data"))
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetSeasonLeague")
}
// Verify player is not on a team in this season_league
players, err := db.GetPlayersNotOnTeam(ctx, tx, sl.Season.ID, sl.League.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
}
playerFound := false
for _, p := range players {
if p.ID == playerID {
playerFound = true
break
}
}
if !playerFound {
notify.Warn(s, w, r, "Cannot Register", "Player is already on a team in this league.", nil)
return false, nil
}
// Check if already registered
isRegistered, err := db.IsFreeAgentRegistered(ctx, tx, sl.Season.ID, sl.League.ID, playerID)
if err != nil {
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
}
if isRegistered {
notify.Warn(s, w, r, "Already Registered", "Player is already registered as a free agent.", nil)
return false, nil
}
err = db.RegisterFreeAgent(ctx, tx, sl.Season.ID, sl.League.ID, playerID, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "db.RegisterFreeAgent")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Free Agent Registered", "Player has been registered as a free agent.", nil)
respond.HXRedirect(w, "/seasons/%s/leagues/%s/free-agents", seasonStr, leagueStr)
})
}
// UnregisterFreeAgent handles POST to unregister a player as a free agent
func UnregisterFreeAgent(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name")
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
respond.BadRequest(w, errors.New("failed to parse form"))
return
}
playerID := getter.Int("player_id").Required().Value
if !getter.ValidateAndNotify(s, w, r) {
respond.BadRequest(w, errors.New("invalid form data"))
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetSeasonLeague")
}
err = db.UnregisterFreeAgent(ctx, tx, sl.Season.ID, sl.League.ID, playerID, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "db.UnregisterFreeAgent")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Free Agent Removed", "Player has been unregistered as a free agent.", nil)
respond.HXRedirect(w, "/seasons/%s/leagues/%s/free-agents", seasonStr, leagueStr)
})
}
// NominateFreeAgentHandler handles POST to nominate a free agent for a fixture
func NominateFreeAgentHandler(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
playerID := getter.Int("player_id").Required().Value
teamID := getter.Int("team_id").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) {
// Verify fixture exists and user is a manager
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
// Check if user can nominate: either a manager of the nominating team,
// or has fixtures.manage permission (can nominate for either team)
user := db.CurrentUser(ctx)
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
if !canManage {
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule || userTeamID != teamID {
throw.Forbidden(s, w, r, "You must be a manager of the nominating team", nil)
return false, nil
}
}
// Verify the team_id is actually one of the fixture's teams
if teamID != fixture.HomeTeamID && teamID != fixture.AwayTeamID {
throw.BadRequest(s, w, r, "Invalid team for this fixture", nil)
return false, nil
}
// Verify player is a registered free agent in this season_league
isRegistered, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, playerID)
if err != nil {
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
}
if !isRegistered {
notify.Warn(s, w, r, "Not Registered", "Player is not a registered free agent in this league.", nil)
return false, nil
}
err = db.NominateFreeAgent(ctx, tx, fixtureID, playerID, teamID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Nominate", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.NominateFreeAgent")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Free Agent Nominated", "Free agent has been nominated for this fixture.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// RemoveFreeAgentNominationHandler handles POST to remove a free agent nomination
func RemoveFreeAgentNominationHandler(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
playerID, err := strconv.Atoi(r.PathValue("player_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid player ID", err)
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
// Check if user can remove: either has fixtures.manage permission,
// or is a manager of the team that nominated the free agent
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
if !canManage {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to remove nominations", nil)
return false, nil
}
// Verify the nomination belongs to the user's team
nominations, err := db.GetNominatedFreeAgentsByTeam(ctx, tx, fixtureID, userTeamID)
if err != nil {
return false, errors.Wrap(err, "db.GetNominatedFreeAgentsByTeam")
}
found := false
for _, n := range nominations {
if n.PlayerID == playerID {
found = true
break
}
}
if !found {
throw.Forbidden(s, w, r, "You can only remove nominations made by your team", nil)
return false, nil
}
}
err := db.RemoveFreeAgentNomination(ctx, tx, fixtureID, playerID, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "db.RemoveFreeAgentNomination")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Nomination Removed", "Free agent nomination has been removed.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}

View File

@@ -61,7 +61,7 @@ func NewLeagueSubmit(
if !nameUnique || !shortNameUnique { if !nameUnique || !shortNameUnique {
return true, nil return true, nil
} }
league, err = db.NewLeague(ctx, tx, name, shortname, description, db.NewAudit(r, nil)) league, err = db.NewLeague(ctx, tx, name, shortname, description, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.NewLeague") return false, errors.Wrap(err, "db.NewLeague")
} }

View File

@@ -12,16 +12,20 @@ import (
"git.haelnorr.com/h/oslstats/internal/config" "git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/rbac"
"git.haelnorr.com/h/oslstats/internal/respond" "git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/store" "git.haelnorr.com/h/oslstats/internal/store"
"git.haelnorr.com/h/oslstats/internal/throw" "git.haelnorr.com/h/oslstats/internal/throw"
authview "git.haelnorr.com/h/oslstats/internal/view/authview" authview "git.haelnorr.com/h/oslstats/internal/view/authview"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
) )
func Register( func Register(
s *hws.Server, s *hws.Server,
auth *hwsauth.Authenticator[*db.User, bun.Tx], auth *hwsauth.Authenticator[*db.User, bun.Tx],
conn *db.DB, conn *db.DB,
slapAPI *slapshotapi.SlapAPI,
cfg *config.Config, cfg *config.Config,
store *store.Store, store *store.Store,
) http.Handler { ) http.Handler {
@@ -56,6 +60,7 @@ func Register(
username := r.FormValue("username") username := r.FormValue("username")
unique := false unique := false
var user *db.User var user *db.User
audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user)
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
unique, err = db.IsUnique(ctx, tx, (*db.User)(nil), "username", username) unique, err = db.IsUnique(ctx, tx, (*db.User)(nil), "username", username)
if err != nil { if err != nil {
@@ -64,19 +69,13 @@ func Register(
if !unique { if !unique {
return true, nil return true, nil
} }
user, err = db.CreateUser(ctx, tx, username, details.DiscordUser, db.NewAudit(r, nil)) user, err = registerUser(ctx, tx, username, details, cfg.RBAC, audit)
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.CreateUser") return false, errors.Wrap(err, "registerUser")
} }
err = user.UpdateDiscordToken(ctx, tx, details.Token) err = connectSlapID(ctx, tx, user, details.Token, slapAPI, audit)
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.UpdateDiscordToken") return false, errors.Wrap(err, "connectSlapID")
}
if shouldGrantAdmin(user, cfg.RBAC) {
err := ensureUserHasAdminRole(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "ensureUserHasAdminRole")
}
} }
return true, nil return true, nil
}); !ok { }); !ok {
@@ -96,3 +95,62 @@ func Register(
}, },
) )
} }
func registerUser(ctx context.Context, tx bun.Tx,
username string, details *store.RegistrationSession,
rbac *rbac.Config, audit *db.AuditMeta,
) (*db.User, error) {
// Register the user
user, err := db.CreateUser(ctx, tx, username, details.DiscordUser, audit)
if err != nil {
return nil, errors.Wrap(err, "db.CreateUser")
}
err = user.UpdateDiscordToken(ctx, tx, details.Token)
if err != nil {
return nil, errors.Wrap(err, "db.UpdateDiscordToken")
}
err = user.ConnectPlayer(ctx, tx, audit)
if err != nil {
return nil, errors.Wrap(err, "db.ConnectPlayer")
}
// Check if they should be an admin
if shouldGrantAdmin(user, rbac) {
err := ensureUserHasAdminRole(ctx, tx, user)
if err != nil {
return nil, errors.Wrap(err, "ensureUserHasAdminRole")
}
}
return user, nil
}
func connectSlapID(ctx context.Context, tx bun.Tx, user *db.User,
token *discord.Token, slapAPI *slapshotapi.SlapAPI, audit *db.AuditMeta,
) error {
// Attempt to setup their player/slapID from steam connection
// If fails due to no steam connection or no slapID, fail silently and proceed with registration
session, err := discord.NewOAuthSession(token)
if err != nil {
return errors.Wrap(err, "discord.NewOAuthSession")
}
steamID, err := session.GetSteamID()
if err != nil {
if err == discord.ErrNoSteam {
return nil
}
return errors.Wrap(err, "session.GetSteamID")
}
slapID, err := slapAPI.GetSlapID(ctx, steamID)
if err != nil {
if err == slapshotapi.ErrNoSlapID {
return nil
}
return errors.Wrap(err, "slapAPI.GetSlapID")
}
// slapID exists, we can update their player connection
err = db.UpdatePlayerSlapID(ctx, tx, user.Player.ID, slapID, audit)
if err != nil {
return errors.Wrap(err, "db.UpdatePlayerSlapID")
}
return nil
}

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
@@ -41,6 +42,9 @@ func SeasonPage(
}); !ok { }); !ok {
return return
} }
if season.Type == db.SeasonTypeDraft.String() {
http.Redirect(w, r, fmt.Sprintf("/seasons/%s/leagues/%s", season.ShortName, "Draft"), http.StatusTemporaryRedirect)
}
renderSafely(seasonsview.DetailPage(season, leaguesWithTeams), s, r, w) renderSafely(seasonsview.DetailPage(season, leaguesWithTeams), s, r, w)
}) })
} }

View File

@@ -86,7 +86,7 @@ func SeasonEditSubmit(
} }
return false, errors.Wrap(err, "db.GetSeason") return false, errors.Wrap(err, "db.GetSeason")
} }
err = season.Update(ctx, tx, version, start, end, finalsStart, finalsEnd, db.NewAudit(r, nil)) err = season.Update(ctx, tx, version, start, end, finalsStart, finalsEnd, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
return false, errors.Wrap(err, "season.Update") return false, errors.Wrap(err, "season.Update")
} }

View File

@@ -18,8 +18,8 @@ func SeasonLeagueAddTeam(
conn *db.DB, conn *db.DB,
) http.Handler { ) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name") seasonShortName := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name") leagueShortName := r.PathValue("league_short_name")
getter, ok := validation.ParseFormOrNotify(s, w, r) getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok { if !ok {
@@ -36,7 +36,7 @@ func SeasonLeagueAddTeam(
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
team, season, league, err = db.NewTeamParticipation(ctx, tx, seasonStr, leagueStr, teamID, db.NewAudit(r, nil)) team, season, league, err = db.NewTeamParticipation(ctx, tx, seasonShortName, leagueShortName, teamID, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)

View File

@@ -21,11 +21,11 @@ func SeasonLeaguePage(
seasonStr := r.PathValue("season_short_name") seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name") leagueStr := r.PathValue("league_short_name")
var season *db.Season var sl *db.SeasonLeague
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
season, _, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path) throw.NotFound(s, w, r, r.URL.Path)
@@ -38,7 +38,7 @@ func SeasonLeaguePage(
return return
} }
defaultTab := season.GetDefaultTab() defaultTab := sl.Season.GetDefaultTab()
redirectURL := fmt.Sprintf( redirectURL := fmt.Sprintf(
"/seasons/%s/leagues/%s/%s", "/seasons/%s/leagues/%s/%s",
seasonStr, leagueStr, defaultTab, seasonStr, leagueStr, defaultTab,

View File

@@ -21,12 +21,11 @@ func SeasonLeagueFinalsPage(
seasonStr := r.PathValue("season_short_name") seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name") leagueStr := r.PathValue("league_short_name")
var season *db.Season var sl *db.SeasonLeague
var league *db.League
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path) throw.NotFound(s, w, r, r.URL.Path)
@@ -40,7 +39,7 @@ func SeasonLeagueFinalsPage(
} }
if r.Method == "GET" { if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league), s, r, w) renderSafely(seasonsview.SeasonLeagueFinalsPage(sl.Season, sl.League), s, r, w)
} else { } else {
renderSafely(seasonsview.SeasonLeagueFinals(), s, r, w) renderSafely(seasonsview.SeasonLeagueFinals(), s, r, w)
} }

View File

@@ -22,13 +22,14 @@ func SeasonLeagueFixturesPage(
seasonShortName := r.PathValue("season_short_name") seasonShortName := r.PathValue("season_short_name")
leagueShortName := r.PathValue("league_short_name") leagueShortName := r.PathValue("league_short_name")
var season *db.Season var sl *db.SeasonLeague
var league *db.League
var fixtures []*db.Fixture var fixtures []*db.Fixture
var scheduleMap map[int]*db.FixtureSchedule
var resultMap map[int]*db.FixtureResult
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName) sl, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path) throw.NotFound(s, w, r, r.URL.Path)
@@ -36,15 +37,27 @@ func SeasonLeagueFixturesPage(
} }
return false, errors.Wrap(err, "db.GetFixtures") return false, errors.Wrap(err, "db.GetFixtures")
} }
fixtureIDs := make([]int, len(fixtures))
for i, f := range fixtures {
fixtureIDs[i] = f.ID
}
scheduleMap, err = db.GetAcceptedSchedulesForFixtures(ctx, tx, fixtureIDs)
if err != nil {
return false, errors.Wrap(err, "db.GetAcceptedSchedulesForFixtures")
}
resultMap, err = db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
if err != nil {
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures")
}
return true, nil return true, nil
}); !ok { }); !ok {
return return
} }
if r.Method == "GET" { if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueFixturesPage(season, league, fixtures), s, r, w) renderSafely(seasonsview.SeasonLeagueFixturesPage(sl.Season, sl.League, fixtures, scheduleMap, resultMap), s, r, w)
} else { } else {
renderSafely(seasonsview.SeasonLeagueFixtures(season, league, fixtures), s, r, w) renderSafely(seasonsview.SeasonLeagueFixtures(sl.Season, sl.League, fixtures, scheduleMap, resultMap), s, r, w)
} }
}) })
} }
@@ -57,13 +70,12 @@ func SeasonLeagueManageFixturesPage(
seasonShortName := r.PathValue("season_short_name") seasonShortName := r.PathValue("season_short_name")
leagueShortName := r.PathValue("league_short_name") leagueShortName := r.PathValue("league_short_name")
var season *db.Season var sl *db.SeasonLeague
var league *db.League
var fixtures []*db.Fixture var fixtures []*db.Fixture
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName) sl, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path) throw.NotFound(s, w, r, r.URL.Path)
@@ -76,7 +88,7 @@ func SeasonLeagueManageFixturesPage(
return return
} }
renderSafely(seasonsview.SeasonLeagueManageFixturesPage(season, league, fixtures), s, r, w) renderSafely(seasonsview.SeasonLeagueManageFixturesPage(sl.Season, sl.League, fixtures), s, r, w)
}) })
} }
@@ -88,11 +100,10 @@ func SeasonLeagueDeleteFixtures(
seasonShortName := r.PathValue("season_short_name") seasonShortName := r.PathValue("season_short_name")
leagueShortName := r.PathValue("league_short_name") leagueShortName := r.PathValue("league_short_name")
var season *db.Season var sl *db.SeasonLeague
var league *db.League
var fixtures []*db.Fixture var fixtures []*db.Fixture
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
err := db.DeleteAllFixtures(ctx, tx, seasonShortName, leagueShortName, db.NewAudit(r, nil)) err := db.DeleteAllFixtures(ctx, tx, seasonShortName, leagueShortName, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.DeleteAllFixtures")) respond.BadRequest(w, errors.Wrap(err, "db.DeleteAllFixtures"))
@@ -100,7 +111,7 @@ func SeasonLeagueDeleteFixtures(
} }
return false, errors.Wrap(err, "db.DeleteAllFixtures") return false, errors.Wrap(err, "db.DeleteAllFixtures")
} }
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName) sl, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.GetFixtures") return false, errors.Wrap(err, "db.GetFixtures")
} }
@@ -109,6 +120,6 @@ func SeasonLeagueDeleteFixtures(
return return
} }
renderSafely(seasonsview.SeasonLeagueManageFixtures(season, league, fixtures), s, r, w) renderSafely(seasonsview.SeasonLeagueManageFixtures(sl.Season, sl.League, fixtures), s, r, w)
}) })
} }

View File

@@ -21,12 +21,11 @@ func SeasonLeagueStatsPage(
seasonStr := r.PathValue("season_short_name") seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name") leagueStr := r.PathValue("league_short_name")
var season *db.Season var sl *db.SeasonLeague
var league *db.League
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path) throw.NotFound(s, w, r, r.URL.Path)
@@ -40,7 +39,7 @@ func SeasonLeagueStatsPage(
} }
if r.Method == "GET" { if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueStatsPage(season, league), s, r, w) renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League), s, r, w)
} else { } else {
renderSafely(seasonsview.SeasonLeagueStats(), s, r, w) renderSafely(seasonsview.SeasonLeagueStats(), s, r, w)
} }

View File

@@ -23,25 +23,46 @@ func SeasonLeagueTablePage(
var season *db.Season var season *db.Season
var league *db.League var league *db.League
var leaderboard []*db.LeaderboardEntry
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) var teams []*db.Team
season, league, teams, err = db.GetSeasonLeagueWithTeams(ctx, tx, seasonStr, leagueStr)
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path) throw.NotFound(s, w, r, r.URL.Path)
return false, nil return false, nil
} }
return false, errors.Wrap(err, "db.GetSeasonLeague") return false, errors.Wrap(err, "db.GetSeasonLeagueWithTeams")
} }
fixtures, err := db.GetAllocatedFixtures(ctx, tx, season.ID, league.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetAllocatedFixtures")
}
fixtureIDs := make([]int, len(fixtures))
for i, f := range fixtures {
fixtureIDs[i] = f.ID
}
resultMap, err := db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
if err != nil {
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures")
}
leaderboard = db.ComputeLeaderboard(teams, fixtures, resultMap)
return true, nil return true, nil
}); !ok { }); !ok {
return return
} }
if r.Method == "GET" { if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueTablePage(season, league), s, r, w) renderSafely(seasonsview.SeasonLeagueTablePage(season, league, leaderboard), s, r, w)
} else { } else {
renderSafely(seasonsview.SeasonLeagueTable(), s, r, w) renderSafely(seasonsview.SeasonLeagueTable(leaderboard), s, r, w)
} }
}) })
} }

View File

@@ -0,0 +1,83 @@
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/throw"
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// SeasonLeagueTeamDetailPage renders the detail page for a team within a season league
func SeasonLeagueTeamDetailPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonShortName := r.PathValue("season_short_name")
leagueShortName := r.PathValue("league_short_name")
teamIDStr := r.PathValue("team_id")
teamID, err := strconv.Atoi(teamIDStr)
if err != nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
var twr *db.TeamWithRoster
var fixtures []*db.Fixture
var available []*db.Player
var scheduleMap map[int]*db.FixtureSchedule
var resultMap map[int]*db.FixtureResult
var playerStats []*db.AggregatedPlayerStats
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
twr, err = db.GetTeamRoster(ctx, tx, seasonShortName, leagueShortName, teamID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetTeamRoster")
}
fixtures, err = db.GetFixturesForTeam(ctx, tx, twr.Season.ID, twr.League.ID, twr.Team.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixturesForTeam")
}
fixtureIDs := make([]int, len(fixtures))
for i, f := range fixtures {
fixtureIDs[i] = f.ID
}
scheduleMap, err = db.GetAcceptedSchedulesForFixtures(ctx, tx, fixtureIDs)
if err != nil {
return false, errors.Wrap(err, "db.GetAcceptedSchedulesForFixtures")
}
resultMap, err = db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
if err != nil {
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures")
}
playerStats, err = db.GetAggregatedPlayerStatsForTeam(ctx, tx, teamID, fixtureIDs)
if err != nil {
return false, errors.Wrap(err, "db.GetAggregatedPlayerStatsForTeam")
}
available, err = db.GetPlayersNotOnTeam(ctx, tx, twr.Season.ID, twr.League.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
}
return true, nil
}); !ok {
return
}
record := db.ComputeTeamRecord(teamID, fixtures, resultMap)
renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available, scheduleMap, resultMap, record, playerStats), s, r, w)
})
}

View File

@@ -25,10 +25,11 @@ func SeasonLeagueTeamsPage(
var league *db.League var league *db.League
var teams []*db.Team var teams []*db.Team
var available []*db.Team var available []*db.Team
var managers map[int]*db.Player
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
season, league, teams, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) season, league, teams, err = db.GetSeasonLeagueWithTeams(ctx, tx, seasonStr, leagueStr)
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path) throw.NotFound(s, w, r, r.URL.Path)
@@ -45,15 +46,20 @@ func SeasonLeagueTeamsPage(
return false, errors.Wrap(err, "db.GetList[Team]") return false, errors.Wrap(err, "db.GetList[Team]")
} }
managers, err = db.GetManagersByTeam(ctx, tx, season.ID, league.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetManagersByTeam")
}
return true, nil return true, nil
}); !ok { }); !ok {
return return
} }
if r.Method == "GET" { if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueTeamsPage(season, league, teams, available), s, r, w) renderSafely(seasonsview.SeasonLeagueTeamsPage(season, league, teams, available, managers), s, r, w)
} else { } else {
renderSafely(seasonsview.SeasonLeagueTeams(season, league, teams, available), s, r, w) renderSafely(seasonsview.SeasonLeagueTeams(season, league, teams, available, managers), s, r, w)
} }
}) })
} }

View File

@@ -19,13 +19,13 @@ func SeasonAddLeague(
conn *db.DB, conn *db.DB,
) http.Handler { ) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name") seasonShortName := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name") leagueShortName := r.PathValue("league_short_name")
var season *db.Season var season *db.Season
var allLeagues []*db.League var allLeagues []*db.League
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
err := db.NewSeasonLeague(ctx, tx, seasonStr, leagueStr, db.NewAudit(r, nil)) err := db.NewSeasonLeague(ctx, tx, seasonShortName, leagueShortName, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
respond.BadRequest(w, err) respond.BadRequest(w, err)
@@ -35,7 +35,7 @@ func SeasonAddLeague(
} }
// Reload season with updated leagues // Reload season with updated leagues
season, err = db.GetSeason(ctx, tx, seasonStr) season, err = db.GetSeason(ctx, tx, seasonShortName)
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.GetSeason") return false, errors.Wrap(err, "db.GetSeason")
} }
@@ -75,7 +75,7 @@ func SeasonRemoveLeague(
} }
return false, errors.Wrap(err, "db.GetSeason") return false, errors.Wrap(err, "db.GetSeason")
} }
err = season.RemoveLeague(ctx, tx, leagueStr, db.NewAudit(r, nil)) err = season.RemoveLeague(ctx, tx, leagueStr, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
if db.IsBadRequest(err) { if db.IsBadRequest(err) {
respond.BadRequest(w, err) respond.BadRequest(w, err)

View File

@@ -16,11 +16,13 @@ import (
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
// NewSeason handles GET requests - redirects to the seasons list
// The form is now in a modal on the list page
func NewSeason( func NewSeason(
s *hws.Server, s *hws.Server,
) http.Handler { ) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
renderSafely(seasonsview.NewPage(), s, r, w) respond.HXRedirect(w, "/seasons")
}) })
} }
@@ -41,6 +43,8 @@ func NewSeasonSubmit(
MaxLength(6).MinLength(2).Value MaxLength(6).MinLength(2).Value
version := getter.String("slap_version"). version := getter.String("slap_version").
TrimSpace().Required().AllowedValues([]string{"rebound", "slapshot1"}).Value TrimSpace().Required().AllowedValues([]string{"rebound", "slapshot1"}).Value
type_ := getter.String("type").
TrimSpace().Required().AllowedValues([]string{"regular", "draft"}).Value
format := timefmt.NewBuilder(). format := timefmt.NewBuilder().
DayNumeric2().Slash(). DayNumeric2().Slash().
MonthNumeric2().Slash(). MonthNumeric2().Slash().
@@ -52,7 +56,6 @@ func NewSeasonSubmit(
nameUnique := false nameUnique := false
shortNameUnique := false shortNameUnique := false
var season *db.Season
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
nameUnique, err = db.IsUnique(ctx, tx, (*db.Season)(nil), "name", name) nameUnique, err = db.IsUnique(ctx, tx, (*db.Season)(nil), "name", name)
@@ -66,7 +69,7 @@ func NewSeasonSubmit(
if !nameUnique || !shortNameUnique { if !nameUnique || !shortNameUnique {
return true, nil return true, nil
} }
season, err = db.NewSeason(ctx, tx, name, version, shortname, start, db.NewAudit(r, nil)) _, err = db.NewSeason(ctx, tx, name, version, shortname, type_, start, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.NewSeason") return false, errors.Wrap(err, "db.NewSeason")
} }
@@ -84,7 +87,26 @@ func NewSeasonSubmit(
notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil) notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil)
return return
} }
respond.HXRedirect(w, "/seasons/%s", season.ShortName)
notify.SuccessWithDelay(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil) // Return the updated seasons list
pageOpts := &db.PageOpts{
Page: 1,
PerPage: 10,
Order: bun.OrderDesc,
OrderBy: "start_date",
}
var seasons *db.List[db.Season]
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
seasons, err = db.ListSeasons(ctx, tx, pageOpts)
if err != nil {
return false, errors.Wrap(err, "db.ListSeasons")
}
return true, nil
}); !ok {
return
}
renderSafely(seasonsview.SeasonsList(seasons), s, r, w)
notify.Success(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil)
}) })
} }

View File

@@ -0,0 +1,89 @@
package handlers
import (
"context"
"net/http"
"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/validation"
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// ManageTeamRoster handles saving a full team roster (manager + players)
func ManageTeamRoster(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
respond.BadRequest(w, errors.New("failed to parse form"))
return
}
seasonID := getter.Int("season_id").Required().Value
leagueID := getter.Int("league_id").Required().Value
teamID := getter.Int("team_id").Required().Value
managerID := getter.Int("manager_id").Required().Value
playerIDs := getter.IntList("player_ids").Values()
if !getter.ValidateAndNotify(s, w, r) {
respond.BadRequest(w, errors.New("invalid form data"))
return
}
// Write transaction: manage the roster
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
err := db.ManageTeamRoster(ctx, tx, seasonID, leagueID, teamID, managerID, playerIDs, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "db.ManageTeamRoster")
}
return true, nil
}) {
return
}
// Re-fetch updated data for HTMX swap
var twr *db.TeamWithRoster
var available []*db.Player
if !conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
// We need season/league short names to call GetTeamRoster
season, err := db.GetByID[db.Season](tx, seasonID).Get(ctx)
if err != nil {
return false, errors.Wrap(err, "db.GetSeason")
}
league, err := db.GetByID[db.League](tx, leagueID).Get(ctx)
if err != nil {
return false, errors.Wrap(err, "db.GetLeague")
}
twr, err = db.GetTeamRoster(ctx, tx, season.ShortName, league.ShortName, teamID)
if err != nil {
return false, errors.Wrap(err, "db.GetTeamRoster")
}
available, err = db.GetPlayersNotOnTeam(ctx, tx, seasonID, leagueID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
}
return true, nil
}) {
return
}
// Respond with HTMX swap of the roster section
w.Header().Set("HX-Retarget", "#team-roster-section")
w.Header().Set("HX-Reswap", "outerHTML")
notify.Success(s, w, r, "Roster Updated", "Team roster has been saved successfully.", nil)
renderSafely(seasonsview.TeamRosterSection(twr, available), s, r, w)
})
}

View File

@@ -35,7 +35,7 @@ func NewTeamSubmit(
} }
name := getter.String("name"). name := getter.String("name").
TrimSpace().Required(). TrimSpace().Required().
MaxLength(25).MinLength(3).Value MaxLength(50).MinLength(3).Value
shortName := getter.String("short_name"). shortName := getter.String("short_name").
TrimSpace().Required().ToUpper(). TrimSpace().Required().ToUpper().
MaxLength(3).MinLength(3).Value MaxLength(3).MinLength(3).Value
@@ -71,7 +71,7 @@ func NewTeamSubmit(
if !nameUnique || !shortNameComboUnique { if !nameUnique || !shortNameComboUnique {
return true, nil return true, nil
} }
_, err = db.NewTeam(ctx, tx, name, shortName, altShortName, color, db.NewAudit(r, nil)) _, err = db.NewTeam(ctx, tx, name, shortName, altShortName, color, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.NewTeam") return false, errors.Wrap(err, "db.NewTeam")
} }

View File

@@ -28,6 +28,7 @@ const (
TeamsUpdate Permission = "teams.update" TeamsUpdate Permission = "teams.update"
TeamsDelete Permission = "teams.delete" TeamsDelete Permission = "teams.delete"
TeamsAddToLeague Permission = "teams.add_to_league" TeamsAddToLeague Permission = "teams.add_to_league"
TeamsManagePlayers Permission = "teams.manage_players"
// Users permissions // Users permissions
UsersUpdate Permission = "users.update" UsersUpdate Permission = "users.update"
@@ -38,4 +39,8 @@ const (
FixturesManage Permission = "fixtures.manage" FixturesManage Permission = "fixtures.manage"
FixturesCreate Permission = "fixtures.create" FixturesCreate Permission = "fixtures.create"
FixturesDelete Permission = "fixtures.delete" FixturesDelete Permission = "fixtures.delete"
// Free Agent permissions
FreeAgentsAdd Permission = "free_agents.add"
FreeAgentsRemove Permission = "free_agents.remove"
) )

View File

@@ -28,8 +28,10 @@ func LoadPreviewRoleMiddleware(s *hws.Server, conn *db.DB) func(http.Handler) ht
user := db.CurrentUser(r.Context()) user := db.CurrentUser(r.Context())
if user == nil { if user == nil {
// User not logged in, // User not logged in
ClearPreviewRoleCookie(w) // Auth middleware skips on certain routes like CSS files so even
// if user IS logged in, this will trigger on those routes,
// so we just pass the request on and do nothing.
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }

View File

@@ -15,6 +15,7 @@ import (
"git.haelnorr.com/h/oslstats/internal/permissions" "git.haelnorr.com/h/oslstats/internal/permissions"
"git.haelnorr.com/h/oslstats/internal/rbac" "git.haelnorr.com/h/oslstats/internal/rbac"
"git.haelnorr.com/h/oslstats/internal/store" "git.haelnorr.com/h/oslstats/internal/store"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
) )
func addRoutes( func addRoutes(
@@ -25,6 +26,7 @@ func addRoutes(
auth *hwsauth.Authenticator[*db.User, bun.Tx], auth *hwsauth.Authenticator[*db.User, bun.Tx],
store *store.Store, store *store.Store,
discordAPI *discord.APIClient, discordAPI *discord.APIClient,
slapAPI *slapshotapi.SlapAPI,
perms *rbac.Checker, perms *rbac.Checker,
) error { ) error {
// Create the routes // Create the routes
@@ -55,7 +57,7 @@ func addRoutes(
{ {
Path: "/register", Path: "/register",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: auth.LogoutReq(handlers.Register(s, auth, conn, cfg, store)), Handler: auth.LogoutReq(handlers.Register(s, auth, conn, slapAPI, cfg, store)),
}, },
{ {
Path: "/logout", Path: "/logout",
@@ -115,6 +117,11 @@ func addRoutes(
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.SeasonLeagueTeamsPage(s, conn), Handler: handlers.SeasonLeagueTeamsPage(s, conn),
}, },
{
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/teams/{team_id}",
Method: hws.MethodGET,
Handler: handlers.SeasonLeagueTeamDetailPage(s, conn),
},
{ {
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/stats", Path: "/seasons/{season_short_name}/leagues/{league_short_name}/stats",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
@@ -140,6 +147,22 @@ func addRoutes(
Method: hws.MethodPOST, Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.TeamsAddToLeague)(handlers.SeasonLeagueAddTeam(s, conn)), Handler: perms.RequirePermission(s, permissions.TeamsAddToLeague)(handlers.SeasonLeagueAddTeam(s, conn)),
}, },
// Free agent routes
{
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/free-agents",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.FreeAgentsListPage(s, conn),
},
{
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/free-agents/register",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FreeAgentsAdd)(handlers.RegisterFreeAgent(s, conn)),
},
{
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/free-agents/unregister",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FreeAgentsRemove)(handlers.UnregisterFreeAgent(s, conn)),
},
} }
leagueRoutes := []hws.Route{ leagueRoutes := []hws.Route{
@@ -181,11 +204,89 @@ func addRoutes(
Method: hws.MethodDELETE, Method: hws.MethodDELETE,
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.SeasonLeagueDeleteFixtures(s, conn)), Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.SeasonLeagueDeleteFixtures(s, conn)),
}, },
{
Path: "/fixtures/{fixture_id}",
Method: hws.MethodGET,
Handler: handlers.FixtureDetailPage(s, conn),
},
{ {
Path: "/fixtures/{fixture_id}", Path: "/fixtures/{fixture_id}",
Method: hws.MethodDELETE, Method: hws.MethodDELETE,
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)), Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)),
}, },
// Fixture scheduling routes
{
Path: "/fixtures/{fixture_id}/schedule",
Method: hws.MethodPOST,
Handler: handlers.ProposeSchedule(s, conn),
},
{
Path: "/fixtures/{fixture_id}/schedule/{schedule_id}/accept",
Method: hws.MethodPOST,
Handler: handlers.AcceptSchedule(s, conn),
},
{
Path: "/fixtures/{fixture_id}/schedule/{schedule_id}/reject",
Method: hws.MethodPOST,
Handler: handlers.RejectSchedule(s, conn),
},
{
Path: "/fixtures/{fixture_id}/schedule/{schedule_id}/withdraw",
Method: hws.MethodPOST,
Handler: handlers.WithdrawSchedule(s, conn),
},
{
Path: "/fixtures/{fixture_id}/schedule/postpone",
Method: hws.MethodPOST,
Handler: handlers.PostponeSchedule(s, conn),
},
{
Path: "/fixtures/{fixture_id}/schedule/reschedule",
Method: hws.MethodPOST,
Handler: handlers.RescheduleFixture(s, conn),
},
{
Path: "/fixtures/{fixture_id}/schedule/cancel",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.CancelSchedule(s, conn)),
},
// Fixture free agent nomination routes
{
Path: "/fixtures/{fixture_id}/free-agents/nominate",
Method: hws.MethodPOST,
Handler: handlers.NominateFreeAgentHandler(s, conn),
},
{
Path: "/fixtures/{fixture_id}/free-agents/{player_id}/remove",
Method: hws.MethodPOST,
Handler: handlers.RemoveFreeAgentNominationHandler(s, conn),
},
// Match result management routes (all require fixtures.manage permission)
{
Path: "/fixtures/{fixture_id}/results/upload",
Method: hws.MethodGET,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.UploadMatchLogsPage(s, conn)),
},
{
Path: "/fixtures/{fixture_id}/results/upload",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.UploadMatchLogs(s, conn)),
},
{
Path: "/fixtures/{fixture_id}/results/review",
Method: hws.MethodGET,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.ReviewMatchResult(s, conn)),
},
{
Path: "/fixtures/{fixture_id}/results/finalize",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.FinalizeMatchResult(s, conn)),
},
{
Path: "/fixtures/{fixture_id}/results/discard",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.DiscardMatchResult(s, conn)),
},
} }
teamRoutes := []hws.Route{ teamRoutes := []hws.Route{
@@ -204,6 +305,11 @@ func addRoutes(
Method: hws.MethodPOST, Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.TeamsCreate)(handlers.NewTeamSubmit(s, conn)), Handler: perms.RequirePermission(s, permissions.TeamsCreate)(handlers.NewTeamSubmit(s, conn)),
}, },
{
Path: "/teams/manage_roster",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.TeamsManagePlayers)(handlers.ManageTeamRoster(s, conn)),
},
} }
htmxRoutes := []hws.Route{ htmxRoutes := []hws.Route{

View File

@@ -15,6 +15,7 @@ import (
"git.haelnorr.com/h/oslstats/internal/handlers" "git.haelnorr.com/h/oslstats/internal/handlers"
"git.haelnorr.com/h/oslstats/internal/rbac" "git.haelnorr.com/h/oslstats/internal/rbac"
"git.haelnorr.com/h/oslstats/internal/store" "git.haelnorr.com/h/oslstats/internal/store"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
) )
func Setup( func Setup(
@@ -24,6 +25,7 @@ func Setup(
conn *db.DB, conn *db.DB,
store *store.Store, store *store.Store,
discordAPI *discord.APIClient, discordAPI *discord.APIClient,
slapAPI *slapshotapi.SlapAPI,
) (server *hws.Server, err error) { ) (server *hws.Server, err error) {
if staticFS == nil { if staticFS == nil {
return nil, errors.New("No filesystem provided") return nil, errors.New("No filesystem provided")
@@ -67,7 +69,7 @@ func Setup(
return nil, errors.Wrap(err, "rbac.NewChecker") return nil, errors.Wrap(err, "rbac.NewChecker")
} }
err = addRoutes(httpServer, &fs, cfg, conn, auth, store, discordAPI, perms) err = addRoutes(httpServer, &fs, cfg, conn, auth, store, discordAPI, slapAPI, perms)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "addRoutes") return nil, errors.Wrap(err, "addRoutes")
} }

View File

@@ -0,0 +1,24 @@
package validation
import (
"fmt"
"strconv"
)
type BoolField struct {
FieldBase
Value bool
}
func newBoolField(key string, g Getter) *BoolField {
raw := g.Get(key)
val, err := strconv.ParseBool(raw)
if err != nil {
g.AddCheck(newFailedCheck("Invalid boolean value",
fmt.Sprintf("Field %s requires a boolean value, %s given", key, raw)))
}
return &BoolField{
newField(key, g),
val,
}
}

View File

@@ -3,8 +3,8 @@ package validation
import ( import (
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/notify" "git.haelnorr.com/h/oslstats/internal/notify"
@@ -28,22 +28,39 @@ func (f *FormGetter) Get(key string) string {
} }
func (f *FormGetter) GetList(key string) []string { func (f *FormGetter) GetList(key string) []string {
return strings.Split(f.Get(key), ",") if f.r.Form == nil {
return nil
}
values, ok := f.r.Form[key]
if !ok || len(values) == 0 {
return nil
}
// Support both comma-separated single values and multiple form fields
if len(values) == 1 {
return strings.Split(values[0], ",")
}
return values
} }
func (f *FormGetter) GetMaps(key string) []map[string]string { func (f *FormGetter) GetMaps(key string) []map[string]string {
var result []map[string]string results := map[string]map[string]string{}
for key, values := range f.r.Form { re := regexp.MustCompile(key + "\\[([0-9]+)\\]\\[([a-zA-Z_]+)\\]")
re := regexp.MustCompile(key + "\\[([0-9]+)\\]\\[([a-zA-Z]+)\\]") for k, v := range f.r.Form {
matches := re.FindStringSubmatch(key) matches := re.FindStringSubmatch(k)
if len(matches) >= 3 { if len(matches) >= 3 {
index, _ := strconv.Atoi(matches[1]) realKey := matches[1]
for index >= len(result) { field := matches[2]
result = append(result, map[string]string{}) value := strings.Join(v, ",")
if _, exists := results[realKey]; !exists {
results[realKey] = map[string]string{}
} }
result[index][matches[2]] = values[0] results[realKey][field] = value
} }
} }
result := []map[string]string{}
for _, v := range results {
result = append(result, v)
}
return result return result
} }
@@ -67,10 +84,18 @@ func (f *FormGetter) Int(key string) *IntField {
return newIntField(key, f) return newIntField(key, f)
} }
func (f *FormGetter) Bool(key string) *BoolField {
return newBoolField(key, f)
}
func (f *FormGetter) Time(key string, format *timefmt.Format) *TimeField { func (f *FormGetter) Time(key string, format *timefmt.Format) *TimeField {
return newTimeField(key, format, f) return newTimeField(key, format, f)
} }
func (f *FormGetter) TimeInLocation(key string, format *timefmt.Format, loc *time.Location) *TimeField {
return newTimeFieldInLocation(key, format, loc, f)
}
func (f *FormGetter) StringList(key string) *StringList { func (f *FormGetter) StringList(key string) *StringList {
return newStringList(key, f) return newStringList(key, f)
} }

View File

@@ -46,6 +46,10 @@ func (q *QueryGetter) Int(key string) *IntField {
return newIntField(key, q) return newIntField(key, q)
} }
func (q *QueryGetter) Bool(key string) *BoolField {
return newBoolField(key, q)
}
func (q *QueryGetter) Time(key string, format *timefmt.Format) *TimeField { func (q *QueryGetter) Time(key string, format *timefmt.Format) *TimeField {
return newTimeField(key, format, q) return newTimeField(key, format, q)
} }

View File

@@ -31,6 +31,25 @@ func newTimeField(key string, format *timefmt.Format, g Getter) *TimeField {
} }
} }
func newTimeFieldInLocation(key string, format *timefmt.Format, loc *time.Location, g Getter) *TimeField {
raw := g.Get(key)
var startDate time.Time
if raw != "" {
var err error
startDate, err = format.ParseInLocation(raw, loc)
if err != nil {
g.AddCheck(newFailedCheck(
"Invalid date/time format",
fmt.Sprintf("%s should be in format %s", key, format.LDML()),
))
}
}
return &TimeField{
Value: startDate,
FieldBase: newField(key, g),
}
}
func (t *TimeField) Required() *TimeField { func (t *TimeField) Required() *TimeField {
if t.Value.IsZero() { if t.Value.IsZero() {
t.getter.AddCheck(newFailedCheck( t.getter.AddCheck(newFailedCheck(
@@ -48,3 +67,23 @@ func (t *TimeField) Optional() *TimeField {
} }
return t return t
} }
func (t *TimeField) Before(limit time.Time) *TimeField {
if !t.Value.Before(limit) {
t.getter.AddCheck(newFailedCheck(
"Date/Time invalid",
fmt.Sprintf("%s must be before %s", t.Key, limit),
))
}
return t
}
func (t *TimeField) After(limit time.Time) *TimeField {
if !t.Value.After(limit) {
t.getter.AddCheck(newFailedCheck(
"Date/Time invalid",
fmt.Sprintf("%s must be after %s", t.Key, limit),
))
}
return t
}

View File

@@ -25,6 +25,7 @@ type Getter interface {
AddCheck(check *ValidationRule) AddCheck(check *ValidationRule)
String(key string) *StringField String(key string) *StringField
Int(key string) *IntField Int(key string) *IntField
Bool(key string) *BoolField
Time(key string, format *timefmt.Format) *TimeField Time(key string, format *timefmt.Format) *TimeField
StringList(key string) *StringList StringList(key string) *StringList
IntList(key string) *IntList IntList(key string) *IntList

View File

@@ -26,6 +26,7 @@ templ Layout(title string) {
<script src="/static/vendored/htmx@2.0.8.min.js"></script> <script src="/static/vendored/htmx@2.0.8.min.js"></script>
<script src="/static/vendored/htmx-ext-ws.min.js"></script> <script src="/static/vendored/htmx-ext-ws.min.js"></script>
<script src="/static/vendored/alpinejs@3.15.4.min.js" defer></script> <script src="/static/vendored/alpinejs@3.15.4.min.js" defer></script>
<script src="/static/js/localtime.js" defer></script>
if devInfo.HTMXLog { if devInfo.HTMXLog {
<script> <script>
htmx.logAll(); htmx.logAll();

View File

@@ -25,11 +25,12 @@ templ SeasonDetails(season *db.Season, leaguesWithTeams []db.LeagueWithTeams) {
<div class="bg-surface0 border-b border-surface1 px-6 py-8"> <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 class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div> <div>
<h1 class="text-4xl font-bold text-text mb-2">{ season.Name }</h1> <div class="flex items-center gap-3 mb-2">
<h1 class="text-4xl font-bold text-text">{ season.Name }</h1>
<span class="text-lg font-mono text-subtext0 bg-surface1 px-2 py-0.5 rounded">{ season.ShortName }</span>
</div>
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<span class="inline-block bg-blue px-3 py-1 rounded-full text-sm font-semibold text-mantle"> @SeasonTypeBadge(season.Type)
{ season.ShortName }
</span>
@SlapVersionBadge(season.SlapVersion) @SlapVersionBadge(season.SlapVersion)
@StatusBadge(season, false, false) @StatusBadge(season, false, false)
</div> </div>
@@ -221,3 +222,16 @@ templ SlapVersionBadge(version string) {
</span> </span>
} }
} }
templ SeasonTypeBadge(type_ string) {
switch type_ {
case "regular":
<span class="inline-block bg-sapphire px-3 py-1 rounded-full text-sm font-semibold text-mantle">
Regular
</span>
case "draft":
<span class="inline-block bg-mauve px-3 py-1 rounded-full text-sm font-semibold text-mantle">
Draft
</span>
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "fmt"
templ FixtureReviewResultPage(
fixture *db.Fixture,
result *db.FixtureResult,
unmappedPlayers []string,
unnominatedFreeAgents []FreeAgentWarning,
) {
{{
backURL := fmt.Sprintf("/fixtures/%d", fixture.ID)
}}
@baseview.Layout(fmt.Sprintf("Review Result — %s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
<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 Match Result</h1>
<p class="text-sm text-subtext1">
{ fixture.HomeTeam.Name } vs { fixture.AwayTeam.Name }
<span class="text-subtext0 ml-1">
Round { fmt.Sprint(fixture.Round) }
</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 Fixture
</a>
</div>
</div>
</div>
<!-- Warnings Section -->
if result.TamperingDetected || len(unmappedPlayers) > 0 || len(unnominatedFreeAgents) > 0 {
<div class="space-y-4 mb-6">
if result.TamperingDetected && result.TamperingReason != nil {
<div class="bg-red/10 border border-red/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<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-2">
This does not block finalization but should be reviewed carefully.
</p>
</div>
}
if len(unnominatedFreeAgents) > 0 {
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-yellow font-bold text-sm">⚠ Free Agent Nomination Issues</span>
</div>
<p class="text-yellow/80 text-sm mb-2">
The following free agents have nomination issues that should be reviewed before finalizing.
</p>
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
for _, fa := range unnominatedFreeAgents {
<li>
<span class="text-yellow font-medium">{ fa.Name }</span>
<span class="text-yellow/60"> — { fa.Reason }</span>
</li>
}
</ul>
</div>
}
if len(unmappedPlayers) > 0 {
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-yellow font-bold text-sm">⚠ Unmapped Players</span>
</div>
<p class="text-yellow/80 text-sm mb-2">
The following players could not be matched to registered players.
They may be free agents or have unregistered Slapshot IDs.
</p>
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
for _, p := range unmappedPlayers {
<li>{ p }</li>
}
</ul>
</div>
}
</div>
}
<!-- Score Overview -->
<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">Score</h2>
</div>
<div class="p-6">
<div class="flex items-center justify-center gap-8 py-4">
<div class="text-center">
<p class="text-sm text-subtext0 mb-1">{ fixture.HomeTeam.Name }</p>
<p class="text-4xl font-bold text-text">{ 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">{ fixture.AwayTeam.Name }</p>
<p class="text-4xl font-bold text-text">{ 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:
if result.Winner == "home" {
{ fixture.HomeTeam.Name }
} else if result.Winner == "away" {
{ fixture.AwayTeam.Name }
} else {
Draw
}
</span>
</div>
</div>
</div>
<!-- Player Stats Tables -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
@reviewTeamStats(fixture.HomeTeam, result, "home")
@reviewTeamStats(fixture.AwayTeam, result, "away")
</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">
<form
hx-post={ fmt.Sprintf("/fixtures/%d/results/finalize", fixture.ID) }
hx-swap="none"
>
<button
type="submit"
class="px-6 py-3 bg-green hover:bg-green/75 text-mantle rounded-lg
font-medium transition hover:cursor-pointer text-lg"
>
Finalize Result
</button>
</form>
<button
type="button"
@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"
>
Discard & Re-upload
</button>
</div>
</div>
</div>
</div>
}
}
templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
{{
// Collect unique players for this team across all periods
// We'll show the period 3 (final/cumulative) stats
type playerStat struct {
Username string
PlayerID *int
Stats *db.FixtureResultPlayerStats
}
finalStats := []*playerStat{}
seen := map[string]bool{}
// Find period 3 stats for this team (cumulative)
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 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 text-text">
if side == "home" {
Home —
} else {
Away —
}
{ team.Name }
</h3>
</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="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 text-text">
<span class="flex items-center gap-1.5">
{ ps.Username }
if ps.PlayerID == nil {
<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">
FREE AGENT
</span>
}
</span>
</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="8" class="px-3 py-4 text-center text-sm text-subtext1">
No player stats recorded
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
func intPtrStr(v *int) string {
if v == nil {
return "-"
}
return fmt.Sprint(*v)
}

View File

@@ -0,0 +1,118 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "fmt"
templ FixtureUploadResultPage(fixture *db.Fixture) {
{{
backURL := fmt.Sprintf("/fixtures/%d", fixture.ID)
}}
@baseview.Layout(fmt.Sprintf("Upload Result — %s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
<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 Match Logs</h1>
<p class="text-sm text-subtext1">
{ fixture.HomeTeam.Name } vs { fixture.AwayTeam.Name }
<span class="text-subtext0 ml-1">
Round { fmt.Sprint(fixture.Round) }
</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">
<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. Each file corresponds to one period of the match.
The files will be validated for consistency.
</p>
<form
hx-post={ fmt.Sprintf("/fixtures/%d/results/upload", fixture.ID) }
hx-swap="none"
hx-encoding="multipart/form-data"
class="space-y-6"
>
<!-- Period 1 -->
<div>
<label class="block text-sm font-medium text-text mb-2">
Period 1
</label>
<input
type="file"
name="period_1"
accept=".json"
required
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>
<!-- Period 2 -->
<div>
<label class="block text-sm font-medium text-text mb-2">
Period 2
</label>
<input
type="file"
name="period_2"
accept=".json"
required
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>
<!-- Period 3 -->
<div>
<label class="block text-sm font-medium text-text mb-2">
Period 3
</label>
<input
type="file"
name="period_3"
accept=".json"
required
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>
<!-- 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
</button>
</div>
</form>
</div>
</div>
</div>
}
}

View File

@@ -12,8 +12,14 @@ import "git.haelnorr.com/h/oslstats/internal/permissions"
templ ListPage(seasons *db.List[db.Season]) { templ ListPage(seasons *db.List[db.Season]) {
@baseview.Layout("Seasons") { @baseview.Layout("Seasons") {
<!-- Flatpickr CSS -->
<link rel="stylesheet" href="/static/vendored/flatpickr@4.6.13.min.css"/>
<link rel="stylesheet" href="/static/css/flatpickr-catppuccin.css"/>
<!-- Flatpickr JS -->
<script src="/static/vendored/flatpickr@4.6.13.min.js"></script>
<div class="max-w-screen-2xl mx-auto px-2"> <div class="max-w-screen-2xl mx-auto px-2">
@SeasonsList(seasons) @SeasonsList(seasons)
@NewSeasonModal()
</div> </div>
} }
} }
@@ -64,11 +70,12 @@ templ SeasonsList(seasons *db.List[db.Season]) {
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<span class="text-3xl font-bold">Seasons</span> <span class="text-3xl font-bold">Seasons</span>
if canAddSeason { if canAddSeason {
<a <button
href="/seasons/new" type="button"
@click="$dispatch('open-new-season-modal')"
class="rounded-lg px-2 py-1 hover:cursor-pointer text-center text-sm class="rounded-lg px-2 py-1 hover:cursor-pointer text-center text-sm
bg-green hover:bg-green/75 text-mantle transition" bg-green hover:bg-green/75 text-mantle transition"
>Add season</a> >Add season</button>
} }
</div> </div>
@sort.Dropdown(seasons.PageOpts, sortOpts) @sort.Dropdown(seasons.PageOpts, sortOpts)

View File

@@ -0,0 +1,43 @@
package seasonsview
import "time"
// formatISO returns an ISO 8601 UTC string for use in <time datetime="...">.
func formatISO(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format(time.RFC3339)
}
// formatISOUnix returns an ISO 8601 UTC string from a Unix timestamp.
func formatISOUnix(unix int64) string {
return time.Unix(unix, 0).UTC().Format(time.RFC3339)
}
// localtime renders a <time> element that will be formatted client-side
// in the user's local timezone. The format parameter maps to data-localtime:
// "date" → "Mon 2 Jan 2026"
// "time" → "3:04 PM"
// "datetime" → "Mon 2 Jan 2026 at 3:04 PM"
// "short" → "Mon 2 Jan 3:04 PM"
// "histdate" → "2 Jan 2006 15:04"
templ localtime(t *time.Time, format string) {
if t != nil {
<time datetime={ formatISO(t) } data-localtime={ format }>
{ t.UTC().Format("2 Jan 2006 15:04 UTC") }
</time>
} else {
No time set
}
}
// localtimeUnix renders a <time> element from a Unix timestamp.
templ localtimeUnix(unix int64, format string) {
{{
t := time.Unix(unix, 0)
}}
<time datetime={ formatISOUnix(unix) } data-localtime={ format }>
{ t.UTC().Format("2 Jan 2006 15:04 UTC") }
</time>
}

View File

@@ -1,196 +0,0 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/view/datepicker"
templ NewForm() {
<form
hx-post="/seasons/new"
hx-swap="none"
x-data={ templ.JSFuncCall("newSeasonFormData").CallInline }
@submit="handleSubmit()"
@htmx:after-request="if(submitTimeout) clearTimeout(submitTimeout); const redirect = $event.detail.xhr.getResponseHeader('HX-Redirect'); if(redirect) return; if(!$event.detail.successful && $event.detail.xhr.status !== 409) { isSubmitting=false; buttonText='Create Season'; generalError='An error occurred. Please try again.'; }"
>
<script>
function newSeasonFormData() {
return {
canSubmit: false,
buttonText: "Create Season",
// Name validation state
nameError: "",
nameIsChecking: false,
nameIsUnique: false,
nameIsEmpty: true,
// Short name validation state
shortNameError: "",
shortNameIsChecking: false,
shortNameIsUnique: false,
shortNameIsEmpty: true,
// Date validation state
dateError: "",
dateIsEmpty: true,
// Form state
isSubmitting: false,
generalError: "",
submitTimeout: null,
// Reset name errors
resetNameErr() {
this.nameError = "";
this.nameIsChecking = false;
this.nameIsUnique = false;
},
// Reset short name errors
resetShortNameErr() {
this.shortNameError = "";
this.shortNameIsChecking = false;
this.shortNameIsUnique = false;
},
// Reset date errors
resetDateErr() {
this.dateError = "";
},
// Check if form can be submitted
updateCanSubmit() {
this.canSubmit =
!this.nameIsEmpty &&
this.nameIsUnique &&
!this.nameIsChecking &&
!this.shortNameIsEmpty &&
this.shortNameIsUnique &&
!this.shortNameIsChecking &&
!this.dateIsEmpty;
},
// Handle form submission
handleSubmit() {
this.isSubmitting = true;
this.buttonText = "Creating...";
this.generalError = "";
// Set timeout for 10 seconds
this.submitTimeout = setTimeout(() => {
this.isSubmitting = false;
this.buttonText = "Create Season";
this.generalError = "Request timed out. Please try again.";
}, 10000);
},
};
}
</script>
<div class="grid gap-y-5">
<!-- Name Field -->
<div>
<label for="name" class="block text-sm font-medium mb-2">Season Name</label>
<div class="relative">
<input
type="text"
id="name"
name="name"
maxlength="20"
x-bind:class="{
'py-3 px-4 block w-full rounded-lg text-sm bg-base disabled:opacity-50 disabled:pointer-events-none border-2 outline-none': true,
'border-overlay0 focus:border-blue': !nameIsUnique && !nameError,
'border-green focus:border-green': nameIsUnique && !nameIsChecking && !nameError,
'border-red focus:border-red': nameError && !nameIsChecking && !isSubmitting
}"
required
placeholder="e.g. Season 1"
@input="resetNameErr(); nameIsEmpty = $el.value.trim() === ''; if(nameIsEmpty) { nameError='Season name is required'; nameIsUnique=false; } updateCanSubmit();"
hx-post="/htmx/isseasonnameunique"
hx-trigger="input changed delay:500ms"
hx-swap="none"
@htmx:before-request="if($el.value.trim() === '') { nameIsEmpty=true; return; } nameIsEmpty=false; nameIsChecking=true; nameIsUnique=false; nameError=''; updateCanSubmit();"
@htmx:after-request="nameIsChecking=false; if($event.detail.successful) { nameIsUnique=true; } else if($event.detail.xhr.status === 409) { nameError='This season name is already taken'; nameIsUnique=false; } updateCanSubmit();"
/>
<p class="text-xs text-subtext1 mt-1">Maximum 20 characters</p>
</div>
<p
class="text-center text-xs text-red mt-2"
x-show="nameError && !isSubmitting"
x-cloak
x-text="nameError"
></p>
</div>
<!-- Short Name Field -->
<div>
<label for="short_name" class="block text-sm font-medium mb-2">Short Name</label>
<div class="relative">
<input
type="text"
id="short_name"
name="short_name"
maxlength="6"
x-bind:class="{
'py-3 px-4 block w-full rounded-lg text-sm bg-base disabled:opacity-50 disabled:pointer-events-none border-2 outline-none uppercase': true,
'border-overlay0 focus:border-blue': !shortNameIsUnique && !shortNameError,
'border-green focus:border-green': shortNameIsUnique && !shortNameIsChecking && !shortNameError,
'border-red focus:border-red': shortNameError && !shortNameIsChecking && !isSubmitting
}"
required
placeholder="e.g. S1"
pattern="[A-Z0-9]+"
@input="
let val = $el.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
$el.value = val;
resetShortNameErr();
shortNameIsEmpty = val.trim() === '';
if(shortNameIsEmpty) {
shortNameError='Short name is required';
shortNameIsUnique=false;
}
updateCanSubmit();
"
hx-post="/htmx/isseasonshortnameunique"
hx-trigger="input changed delay:500ms"
hx-swap="none"
@htmx:before-request="if($el.value.trim() === '') { shortNameIsEmpty=true; return; } shortNameIsEmpty=false; shortNameIsChecking=true; shortNameIsUnique=false; shortNameError=''; updateCanSubmit();"
@htmx:after-request="shortNameIsChecking=false; if($event.detail.successful) { shortNameIsUnique=true; } else if($event.detail.xhr.status === 409) { shortNameError='This short name is already taken'; shortNameIsUnique=false; } updateCanSubmit();"
/>
<p class="text-xs text-subtext1 mt-1">Maximum 6 characters, alphanumeric only (auto-capitalized)</p>
</div>
<p
class="text-center text-xs text-red mt-2"
x-show="shortNameError && !isSubmitting"
x-cloak
x-text="shortNameError"
></p>
</div>
<!-- Slap Version Field -->
<div>
<label for="slap_version" class="block text-sm font-medium mb-2">Slap Version</label>
<select
id="slap_version"
name="slap_version"
class="py-3 px-4 block w-full rounded-lg text-sm bg-base border-2 border-overlay0 focus:border-blue outline-none"
required
>
<option value="rebound" selected>Rebound</option>
<option value="slapshot1">Slapshot 1</option>
</select>
<p class="text-xs text-subtext1 mt-1">Select the game version for this season</p>
</div>
<!-- Start Date Field -->
@datepicker.DatePicker("start_date", "start_date", "Start Date", "DD/MM/YYYY", true, "dateIsEmpty = $el.value === ''; resetDateErr(); if(dateIsEmpty) { dateError='Start date is required'; } updateCanSubmit();")
<p
class="text-center text-xs text-red mt-2"
x-show="dateError && !isSubmitting"
x-cloak
x-text="dateError"
></p>
<!-- General Error Message -->
<p
class="text-center text-sm text-red"
x-show="generalError"
x-cloak
x-text="generalError"
></p>
<!-- Submit Button -->
<button
x-bind:disabled="!canSubmit || isSubmitting"
x-text="buttonText"
type="submit"
class="w-full py-3 px-4 inline-flex justify-center items-center
gap-x-2 rounded-lg border border-transparent transition font-semibold
bg-blue hover:bg-blue/75 text-mantle hover:cursor-pointer
disabled:bg-blue/40 disabled:cursor-not-allowed"
></button>
</div>
</form>
}

View File

@@ -0,0 +1,275 @@
package seasonsview
// NewFormModal is a version of the form for use in a modal
// It closes the modal and refreshes the list on success instead of redirecting
templ NewFormModal() {
<form
hx-post="/seasons/new"
hx-target="#seasons-list-container"
hx-swap="outerHTML"
x-data={ templ.JSFuncCall("newSeasonFormModalData").CallInline }
@submit="handleSubmit()"
@htmx:after-request="
if(submitTimeout) clearTimeout(submitTimeout);
// Only handle the form submission (not uniqueness checks)
const path = $event.detail.requestConfig.path;
if(path !== '/seasons/new') return;
if($event.detail.successful) {
// Close the modal
$dispatch('close-new-season-modal');
// Reset form
$el.reset();
isSubmitting=false;
buttonText='Create Season';
// Reset validation state
nameIsEmpty=true;
nameIsUnique=false;
shortNameIsEmpty=true;
shortNameIsUnique=false;
dateIsEmpty=true;
updateCanSubmit();
// Clear flatpickr
const fpInput = document.getElementById('start_date');
if(fpInput && fpInput._flatpickr) {
fpInput._flatpickr.clear();
}
} else if($event.detail.xhr.status !== 409) {
isSubmitting=false;
buttonText='Create Season';
generalError='An error occurred. Please try again.';
}
"
x-init="initFlatpickrModal()"
>
<script>
function newSeasonFormModalData() {
return {
canSubmit: false,
buttonText: "Create Season",
// Name validation state
nameError: "",
nameIsChecking: false,
nameIsUnique: false,
nameIsEmpty: true,
// Short name validation state
shortNameError: "",
shortNameIsChecking: false,
shortNameIsUnique: false,
shortNameIsEmpty: true,
// Date validation state
dateError: "",
dateIsEmpty: true,
// Form state
isSubmitting: false,
generalError: "",
submitTimeout: null,
// Reset name errors
resetNameErr() {
this.nameError = "";
this.nameIsChecking = false;
this.nameIsUnique = false;
},
// Reset short name errors
resetShortNameErr() {
this.shortNameError = "";
this.shortNameIsChecking = false;
this.shortNameIsUnique = false;
},
// Reset date errors
resetDateErr() {
this.dateError = "";
},
// Check if form can be submitted
updateCanSubmit() {
this.canSubmit =
!this.nameIsEmpty &&
this.nameIsUnique &&
!this.nameIsChecking &&
!this.shortNameIsEmpty &&
this.shortNameIsUnique &&
!this.shortNameIsChecking &&
!this.dateIsEmpty;
},
// Handle form submission
handleSubmit() {
this.isSubmitting = true;
this.buttonText = "Creating...";
this.generalError = "";
// Set timeout for 10 seconds
this.submitTimeout = setTimeout(() => {
this.isSubmitting = false;
this.buttonText = "Create Season";
this.generalError = "Request timed out. Please try again.";
}, 10000);
},
};
}
function initFlatpickrModal() {
// Small delay to ensure DOM is ready after modal opens
setTimeout(() => {
if (typeof flatpickr !== 'undefined') {
flatpickr('#start_date', {
dateFormat: 'd/m/Y',
allowInput: true,
onChange: function(selectedDates, dateStr) {
const input = document.getElementById('start_date');
input.value = dateStr;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
}
}, 100);
}
</script>
<div class="grid gap-y-5">
<!-- Name and Short Name Fields (Inlined) -->
<div class="grid grid-cols-3 gap-4">
<!-- Name Field -->
<div class="col-span-2">
<label for="name" class="block text-sm font-medium mb-2 text-subtext0">Season Name</label>
<div class="relative">
<input
type="text"
id="name"
name="name"
maxlength="20"
x-bind:class="{
'py-3 px-4 block w-full rounded-lg text-sm bg-surface0 disabled:opacity-50 disabled:pointer-events-none border-2 outline-none text-text placeholder-subtext0': true,
'border-surface1 focus:border-blue': !nameIsUnique && !nameError,
'border-green focus:border-green': nameIsUnique && !nameIsChecking && !nameError,
'border-red focus:border-red': nameError && !nameIsChecking && !isSubmitting
}"
required
placeholder="e.g. Season 1"
@input="resetNameErr(); nameIsEmpty = $el.value.trim() === ''; if(nameIsEmpty) { nameError='Season name is required'; nameIsUnique=false; } updateCanSubmit();"
hx-post="/htmx/isseasonnameunique"
hx-trigger="input changed delay:500ms"
hx-swap="none"
@htmx:before-request="if($el.value.trim() === '') { nameIsEmpty=true; return; } nameIsEmpty=false; nameIsChecking=true; nameIsUnique=false; nameError=''; updateCanSubmit();"
@htmx:after-request="nameIsChecking=false; if($event.detail.successful) { nameIsUnique=true; } else if($event.detail.xhr.status === 409) { nameError='This season name is already taken'; nameIsUnique=false; } updateCanSubmit();"
/>
</div>
<p
class="text-center text-xs text-red mt-2"
x-show="nameError && !isSubmitting"
x-cloak
x-text="nameError"
></p>
</div>
<!-- Short Name Field -->
<div class="col-span-1">
<label for="short_name" class="block text-sm font-medium mb-2 text-subtext0">Short Name</label>
<div class="relative">
<input
type="text"
id="short_name"
name="short_name"
maxlength="6"
x-bind:class="{
'py-3 px-4 block w-full rounded-lg text-sm bg-surface0 disabled:opacity-50 disabled:pointer-events-none border-2 outline-none uppercase text-text placeholder-subtext0': true,
'border-surface1 focus:border-blue': !shortNameIsUnique && !shortNameError,
'border-green focus:border-green': shortNameIsUnique && !shortNameIsChecking && !shortNameError,
'border-red focus:border-red': shortNameError && !shortNameIsChecking && !isSubmitting
}"
required
placeholder="e.g. S1"
pattern="[A-Z0-9]+"
@input="
let val = $el.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
$el.value = val;
resetShortNameErr();
shortNameIsEmpty = val.trim() === '';
if(shortNameIsEmpty) {
shortNameError='Short name is required';
shortNameIsUnique=false;
}
updateCanSubmit();
"
hx-post="/htmx/isseasonshortnameunique"
hx-trigger="input changed delay:500ms"
hx-swap="none"
@htmx:before-request="if($el.value.trim() === '') { shortNameIsEmpty=true; return; } shortNameIsEmpty=false; shortNameIsChecking=true; shortNameIsUnique=false; shortNameError=''; updateCanSubmit();"
@htmx:after-request="shortNameIsChecking=false; if($event.detail.successful) { shortNameIsUnique=true; } else if($event.detail.xhr.status === 409) { shortNameError='This short name is already taken'; shortNameIsUnique=false; } updateCanSubmit();"
/>
</div>
<p
class="text-center text-xs text-red mt-2"
x-show="shortNameError && !isSubmitting"
x-cloak
x-text="shortNameError"
></p>
</div>
</div>
<p class="text-xs text-subtext0 -mt-3">Maximum 20 characters for name, 6 alphanumeric characters for short name</p>
<!-- Season Type and Start Date Fields (Inlined) -->
<div class="grid grid-cols-2 gap-4 items-end">
<!-- Season Type Field -->
<div>
<label for="type" class="block text-sm font-medium mb-2 text-subtext0">Season Type</label>
<select
id="type"
name="type"
class="py-3 px-4 block w-full rounded-lg text-sm bg-surface0 border-2 border-surface1 focus:border-blue outline-none text-text appearance-none cursor-pointer hover:bg-surface1 transition-colors"
required
style="height: 46px;"
>
<option value="regular" selected>Regular</option>
<option value="draft">Draft</option>
</select>
</div>
<!-- Start Date Field (Flatpickr) -->
<div>
<label for="start_date" class="block text-sm font-medium mb-2 text-subtext0">Start Date</label>
<input
type="text"
id="start_date"
name="start_date"
class="py-3 px-4 block w-full rounded-lg text-sm bg-surface0 border-2 border-surface1 focus:border-blue outline-none text-text placeholder-subtext0"
placeholder="DD/MM/YYYY"
required
style="height: 46px;"
@input="dateIsEmpty = $el.value.trim() === ''; resetDateErr(); if(dateIsEmpty) { dateError='Start date is required'; } updateCanSubmit();"
/>
</div>
</div>
<p
class="text-center text-xs text-red -mt-3"
x-show="dateError && !isSubmitting"
x-cloak
x-text="dateError"
></p>
<!-- Slap Version Field -->
<div>
<label for="slap_version" class="block text-sm font-medium mb-2 text-subtext0">Slap Version</label>
<select
id="slap_version"
name="slap_version"
class="py-3 px-4 block w-full rounded-lg text-sm bg-surface0 border-2 border-surface1 focus:border-blue outline-none text-text appearance-none cursor-pointer hover:bg-surface1 transition-colors"
required
style="height: 46px;"
>
<option value="rebound" selected>Rebound</option>
<option value="slapshot1">Slapshot 1</option>
</select>
<p class="text-xs text-subtext0 mt-1">Select the game game version for this season</p>
</div>
<!-- General Error Message -->
<p
class="text-center text-sm text-red"
x-show="generalError"
x-cloak
x-text="generalError"
></p>
<!-- Submit Button -->
<button
x-bind:disabled="!canSubmit || isSubmitting"
x-text="buttonText"
type="submit"
class="w-full py-3 px-4 inline-flex justify-center items-center
gap-x-2 rounded-lg border border-transparent transition-all duration-200 font-semibold
bg-blue hover:bg-blue/80 text-mantle hover:cursor-pointer shadow-md
disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none hover:shadow-lg hover:-translate-y-0.5"
></button>
</div>
</form>
}

View File

@@ -1,23 +0,0 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
templ NewPage() {
@baseview.Layout("New Season") {
<div class="max-w-screen-lg mx-auto px-4 py-8">
<div class="bg-mantle border border-surface1 rounded-xl">
<div class="p-6 sm:p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-text">Create New Season</h1>
<p class="mt-2 text-sm text-subtext0">
Add a new season to the system. All fields are required.
</p>
</div>
<div class="max-w-md mx-auto">
@NewForm()
</div>
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,65 @@
package seasonsview
// NewSeasonModal renders a modal containing the new season form
// This is used on the seasons list page for a better UX
templ NewSeasonModal() {
<div
x-data="{ open: false }"
x-show="open"
x-cloak
@open-new-season-modal.window="open = true"
@close-new-season-modal.window="open = false"
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
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-xl bg-mantle border border-surface0 shadow-xl transition-all w-full max-w-lg"
@click.stop
>
<!-- Header -->
<div class="px-6 py-4 border-b border-surface0">
<div class="flex justify-between items-center">
<h3 class="text-xl font-bold text-text" id="modal-title">Create New Season</h3>
<button
type="button"
@click="open = false"
class="text-subtext0 hover:text-text transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<p class="text-sm text-subtext0 mt-1">Add a new season to the system. All fields are required.</p>
</div>
<!-- Form content -->
<div class="p-6">
@NewFormModal()
</div>
</div>
</div>
</div>
}

View File

@@ -4,14 +4,16 @@ import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/permissions" import "git.haelnorr.com/h/oslstats/internal/permissions"
import "git.haelnorr.com/h/oslstats/internal/contexts" import "git.haelnorr.com/h/oslstats/internal/contexts"
import "fmt" import "fmt"
import "sort"
import "time"
templ SeasonLeagueFixturesPage(season *db.Season, league *db.League, fixtures []*db.Fixture) { templ SeasonLeagueFixturesPage(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) {
@SeasonLeagueLayout("fixtures", season, league) { @SeasonLeagueLayout("fixtures", season, league) {
@SeasonLeagueFixtures(season, league, fixtures) @SeasonLeagueFixtures(season, league, fixtures, scheduleMap, resultMap)
} }
} }
templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture) { templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) {
{{ {{
permCache := contexts.Permissions(ctx) permCache := contexts.Permissions(ctx)
canManage := permCache.HasPermission(permissions.FixturesManage) canManage := permCache.HasPermission(permissions.FixturesManage)
@@ -35,6 +37,23 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
} }
groups[idx].Fixtures = append(groups[idx].Fixtures, f) groups[idx].Fixtures = append(groups[idx].Fixtures, f)
} }
// Sort fixtures within each group by scheduled time
// Scheduled fixtures first (by time), then TBD last
farFuture := time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)
for i := range groups {
sort.Slice(groups[i].Fixtures, func(a, b int) bool {
ta := farFuture
tb := farFuture
if sa, ok := scheduleMap[groups[i].Fixtures[a].ID]; ok && sa.ScheduledTime != nil {
ta = *sa.ScheduledTime
}
if sb, ok := scheduleMap[groups[i].Fixtures[b].ID]; ok && sb.ScheduledTime != nil {
tb = *sb.ScheduledTime
}
return ta.Before(tb)
})
}
}} }}
<div> <div>
if canManage { if canManage {
@@ -55,13 +74,78 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
} else { } else {
<div class="space-y-4"> <div class="space-y-4">
for _, group := range groups { for _, group := range groups {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"> {{
<div class="bg-mantle border-b border-surface1 px-4 py-3"> playedCount := 0
for _, f := range group.Fixtures {
if res, ok := resultMap[f.ID]; ok && res.Finalized {
playedCount++
}
}
hasPlayed := playedCount > 0
allPlayed := playedCount == len(group.Fixtures)
}}
<div
class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"
x-data="{ showPlayed: false }"
>
<div class="bg-mantle border-b border-surface1 px-4 py-3 flex items-center justify-between">
<h3 class="text-lg font-bold text-text">Game Week { fmt.Sprint(group.Week) }</h3> <h3 class="text-lg font-bold text-text">Game Week { fmt.Sprint(group.Week) }</h3>
if hasPlayed {
<button
type="button"
@click="showPlayed = !showPlayed"
class="text-xs px-2.5 py-1 rounded-lg transition cursor-pointer
bg-surface1 hover:bg-surface2 text-subtext0 hover:text-text"
>
<span x-show="!showPlayed">Show played</span>
<span x-show="showPlayed" x-cloak>Hide played</span>
</button>
}
</div> </div>
<div class="divide-y divide-surface1"> <div class="divide-y divide-surface1">
for _, fixture := range group.Fixtures { for _, fixture := range group.Fixtures {
<div class="px-4 py-3 flex items-center justify-between"> {{
sched, hasSchedule := scheduleMap[fixture.ID]
_ = sched
res, hasResult := resultMap[fixture.ID]
_ = res
isPlayed := hasResult && res.Finalized
}}
if isPlayed {
<a
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
x-show="showPlayed"
x-cloak
class="px-4 py-3 flex items-center justify-between hover:bg-surface1 transition hover:cursor-pointer block"
>
@fixtureListItem(fixture, sched, hasSchedule, res, hasResult)
</a>
} else {
<a
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
class="px-4 py-3 flex items-center justify-between hover:bg-surface1 transition hover:cursor-pointer block"
>
@fixtureListItem(fixture, sched, hasSchedule, res, hasResult)
</a>
}
}
</div>
if allPlayed {
<div
x-show="!showPlayed"
class="px-4 py-3 text-center text-xs text-subtext1 italic"
>
All fixtures played
</div>
}
</div>
}
</div>
}
</div>
}
templ fixtureListItem(fixture *db.Fixture, sched *db.FixtureSchedule, hasSchedule bool, res *db.FixtureResult, hasResult bool) {
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded"> <span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded">
R{ fmt.Sprint(fixture.Round) } R{ fmt.Sprint(fixture.Round) }
@@ -74,12 +158,29 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
{ fixture.AwayTeam.Name } { fixture.AwayTeam.Name }
</span> </span>
</div> </div>
</div> if hasResult {
<span class="flex items-center gap-2">
if res.Winner == "home" {
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
} else if res.Winner == "away" {
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
} else {
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
} }
</div> </span>
</div> } else if hasSchedule && sched.ScheduledTime != nil {
<span class="text-xs text-green font-medium">
@localtime(sched.ScheduledTime, "short")
</span>
} else {
<span class="text-xs text-subtext1">
TBD
</span>
} }
</div>
}
</div>
} }

View File

@@ -60,18 +60,19 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<!-- Generate --> <!-- Generate -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <label class="text-sm text-subtext0">Round:</label>
type="number" <select
x-model.number="generateRounds" x-model.number="generateRounds"
min="1" class="py-2 px-3 rounded-lg text-sm bg-base border-2 border-overlay0
max="20"
placeholder="Rounds"
class="w-24 py-2 px-3 rounded-lg text-sm bg-base border-2 border-overlay0
focus:border-blue outline-none text-text" focus:border-blue outline-none text-text"
/> >
<template x-for="r in availableRounds" :key="r">
<option :value="r" x-text="r"></option>
</template>
</select>
<button <button
@click="generate()" @click="generate()"
:disabled="isGenerating || generateRounds < 1" :disabled="isGenerating || availableRounds.length === 0"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-blue hover:bg-blue/80 text-mantle transition bg-blue hover:bg-blue/80 text-mantle transition
disabled:bg-blue/40 disabled:cursor-not-allowed" disabled:bg-blue/40 disabled:cursor-not-allowed"
@@ -79,32 +80,40 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
<span x-text="isGenerating ? 'Generating...' : 'Generate Round'"></span> <span x-text="isGenerating ? 'Generating...' : 'Generate Round'"></span>
</button> </button>
</div> </div>
<!-- Clear All --> <!-- Delete All -->
<button <button
x-show="allFixtures.length > 0" x-show="allFixtures.length > 0"
@click="clearAll()" @click="deleteAll()"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-red hover:bg-red/80 text-mantle transition" bg-red hover:bg-red/80 text-mantle transition"
> >
Clear All Delete All
</button>
<!-- Save / Reset -->
<div x-show="unsavedChanges" class="flex items-center gap-2 ml-auto">
<span
x-show="!canSave()"
class="text-yellow text-xs"
>
All game weeks must have at least 1 fixture
</span>
<button
@click="reset()"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-surface1 hover:bg-surface2 text-text transition"
>
Reset
</button> </button>
<!-- Save -->
<button <button
x-show="unsavedChanges"
@click="save()" @click="save()"
:disabled="isSaving || !canSave()" :disabled="isSaving || !canSave()"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-green hover:bg-green/75 text-mantle transition bg-green hover:bg-green/75 text-mantle transition
disabled:bg-green/40 disabled:cursor-not-allowed ml-auto" disabled:bg-green/40 disabled:cursor-not-allowed"
> >
<span x-text="isSaving ? 'Saving...' : 'Save'"></span> <span x-text="isSaving ? 'Saving...' : 'Save'"></span>
</button> </button>
<span </div>
x-show="unsavedChanges && !canSave()"
class="text-yellow text-xs ml-2"
>
All game weeks must have at least 1 fixture
</span>
</div> </div>
<!-- Main content panels --> <!-- Main content panels -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
@@ -114,7 +123,7 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
<div x-show="selectedGameWeek === null"> <div x-show="selectedGameWeek === null">
<h3 class="text-lg font-bold text-text mb-4">Game Weeks</h3> <h3 class="text-lg font-bold text-text mb-4">Game Weeks</h3>
<div x-show="allGameWeekNumbers.length === 0" class="text-subtext0 text-sm italic mb-4"> <div x-show="allGameWeekNumbers.length === 0" class="text-subtext0 text-sm italic mb-4">
No game weeks yet. Add one to start allocating fixtures. No game weeks yet. Generate fixtures to get started.
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<template x-for="week in allGameWeekNumbers" :key="week"> <template x-for="week in allGameWeekNumbers" :key="week">
@@ -271,9 +280,12 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
</div> </div>
<!-- Alpine.js component --> <!-- Alpine.js component -->
<script> <script>
document.addEventListener('alpine:init', () => { document.addEventListener("alpine:init", () => {
Alpine.data('fixturesManager', (initialFixtures, seasonShortName, leagueShortName) => ({ Alpine.data(
"fixturesManager",
(initialFixtures, seasonShortName, leagueShortName) => ({
allFixtures: initialFixtures || [], allFixtures: initialFixtures || [],
_initialFixtures: JSON.parse(JSON.stringify(initialFixtures || [])),
seasonShortName: seasonShortName, seasonShortName: seasonShortName,
leagueShortName: leagueShortName, leagueShortName: leagueShortName,
@@ -282,7 +294,12 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
unsavedChanges: false, unsavedChanges: false,
isSaving: false, isSaving: false,
isGenerating: false, isGenerating: false,
generateRounds: 1, generateRounds: null,
init() {
this.generateRounds =
this.availableRounds.length > 0 ? this.availableRounds[0] : 1;
},
// Drag state // Drag state
draggedFixture: null, draggedFixture: null,
@@ -291,7 +308,7 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
// Computed // Computed
get unallocatedFixtures() { get unallocatedFixtures() {
return this.allFixtures return this.allFixtures
.filter(f => f.gameWeek === null) .filter((f) => f.gameWeek === null)
.sort((a, b) => a.round - b.round || a.id - b.id); .sort((a, b) => a.round - b.round || a.id - b.id);
}, },
@@ -309,17 +326,41 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
return [...weeks].sort((a, b) => a - b); return [...weeks].sort((a, b) => a - b);
}, },
get existingRounds() {
const rounds = new Set();
for (const f of this.allFixtures) {
rounds.add(f.round);
}
return rounds;
},
get availableRounds() {
const taken = this.existingRounds;
const maxTaken = taken.size > 0 ? Math.max(...taken) : 0;
const limit = maxTaken + 5;
const available = [];
for (let i = 1; i <= limit; i++) {
if (!taken.has(i)) available.push(i);
}
return available;
},
// Track empty weeks that user created // Track empty weeks that user created
_emptyWeeks: [], // Default to [1] if no fixtures have game weeks assigned
_emptyWeeks: (initialFixtures || []).some(
(f) => f.gameWeek !== null,
)
? []
: [1],
getGameWeekFixtures(week) { getGameWeekFixtures(week) {
return this.allFixtures return this.allFixtures
.filter(f => f.gameWeek === week) .filter((f) => f.gameWeek === week)
.sort((a, b) => a.round - b.round || a.id - b.id); .sort((a, b) => a.round - b.round || a.id - b.id);
}, },
getFixtureCount(week) { getFixtureCount(week) {
return this.allFixtures.filter(f => f.gameWeek === week).length; return this.allFixtures.filter((f) => f.gameWeek === week).length;
}, },
getPreview(week) { getPreview(week) {
@@ -336,7 +377,7 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
deleteGameWeek(week) { deleteGameWeek(week) {
if (this.getFixtureCount(week) > 0) return; if (this.getFixtureCount(week) > 0) return;
this._emptyWeeks = this._emptyWeeks.filter(w => w !== week); this._emptyWeeks = this._emptyWeeks.filter((w) => w !== week);
if (this.selectedGameWeek === week) { if (this.selectedGameWeek === week) {
this.selectedGameWeek = null; this.selectedGameWeek = null;
} }
@@ -354,8 +395,8 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
// Drag and drop // Drag and drop
onDragStart(event, fixture) { onDragStart(event, fixture) {
this.draggedFixture = fixture; this.draggedFixture = fixture;
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData('text/plain', fixture.id); event.dataTransfer.setData("text/plain", fixture.id);
}, },
onDragEnd() { onDragEnd() {
@@ -366,15 +407,17 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
onDrop(target) { onDrop(target) {
if (!this.draggedFixture) return; if (!this.draggedFixture) return;
const fixture = this.allFixtures.find(f => f.id === this.draggedFixture.id); const fixture = this.allFixtures.find(
(f) => f.id === this.draggedFixture.id,
);
if (!fixture) return; if (!fixture) return;
if (target === 'unallocated') { if (target === "unallocated") {
fixture.gameWeek = null; fixture.gameWeek = null;
} else { } else {
fixture.gameWeek = target; fixture.gameWeek = target;
// Remove from empty weeks if it now has fixtures // Remove from empty weeks if it now has fixtures
this._emptyWeeks = this._emptyWeeks.filter(w => w !== target); this._emptyWeeks = this._emptyWeeks.filter((w) => w !== target);
} }
this.unsavedChanges = true; this.unsavedChanges = true;
@@ -383,7 +426,7 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
}, },
unallocateFixture(fixture) { unallocateFixture(fixture) {
const f = this.allFixtures.find(ff => ff.id === fixture.id); const f = this.allFixtures.find((ff) => ff.id === fixture.id);
if (f) { if (f) {
const oldWeek = f.gameWeek; const oldWeek = f.gameWeek;
f.gameWeek = null; f.gameWeek = null;
@@ -405,21 +448,36 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
return true; return true;
}, },
reset() {
this.allFixtures = JSON.parse(
JSON.stringify(this._initialFixtures),
);
this._emptyWeeks = this._initialFixtures.some(
(f) => f.gameWeek !== null,
)
? []
: [1];
this.selectedGameWeek = null;
this.unsavedChanges = false;
},
// Server actions // Server actions
generate() { generate() {
if (this.generateRounds < 1) return; if (this.generateRounds < 1) return;
this.isGenerating = true; this.isGenerating = true;
const form = new FormData(); const form = new FormData();
form.append('season_short_name', this.seasonShortName); form.append("season_short_name", this.seasonShortName);
form.append('league_short_name', this.leagueShortName); form.append("league_short_name", this.leagueShortName);
form.append('round', this.generateRounds); form.append("round", this.generateRounds);
htmx.ajax('POST', '/fixtures/generate', { htmx
target: '#manage-fixtures-content', .ajax("POST", "/fixtures/generate", {
swap: 'outerHTML', target: "#manage-fixtures-content",
values: Object.fromEntries(form) swap: "outerHTML",
}).finally(() => { values: Object.fromEntries(form),
})
.finally(() => {
this.isGenerating = false; this.isGenerating = false;
}); });
}, },
@@ -428,49 +486,66 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
if (!this.canSave()) return; if (!this.canSave()) return;
this.isSaving = true; this.isSaving = true;
const form = new FormData(); const params = new URLSearchParams();
form.append('season_short_name', this.seasonShortName); params.append("season_short_name", this.seasonShortName);
form.append('league_short_name', this.leagueShortName); params.append("league_short_name", this.leagueShortName);
this.allFixtures.forEach((f, i) => { this.allFixtures.forEach((f, i) => {
form.append('allocations[' + i + '][id]', f.id); params.append("allocations[" + i + "][id]", f.id);
form.append('allocations[' + i + '][game_week]', f.gameWeek !== null ? f.gameWeek : 0); params.append(
"allocations[" + i + "][game_week]",
f.gameWeek !== null ? f.gameWeek : 0,
);
}); });
fetch('/fixtures/update-game-weeks', { fetch("/fixtures/update-game-weeks", {
method: 'POST', method: "POST",
body: form headers: {
}).then(response => { "Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
})
.then((response) => {
if (response.ok) { if (response.ok) {
this.unsavedChanges = false; this.unsavedChanges = false;
} }
this.isSaving = false; this.isSaving = false;
}).catch(() => { })
.catch(() => {
this.isSaving = false; this.isSaving = false;
}); });
}, },
clearAll() { deleteAll() {
const seasonShort = this.seasonShortName; const seasonShort = this.seasonShortName;
const leagueShort = this.leagueShortName; const leagueShort = this.leagueShortName;
window.dispatchEvent(new CustomEvent('confirm-action', { window.dispatchEvent(
new CustomEvent("confirm-action", {
detail: { detail: {
title: 'Clear All Fixtures', title: "Delete All Fixtures",
message: 'This will delete all fixtures for this league. This action cannot be undone.', message:
"This will delete all fixtures for this league. This action cannot be undone.",
action: () => { action: () => {
htmx.ajax('DELETE', htmx.ajax(
'/seasons/' + seasonShort + '/leagues/' + leagueShort + '/fixtures', "DELETE",
"/seasons/" +
seasonShort +
"/leagues/" +
leagueShort +
"/fixtures",
{ {
target: '#manage-fixtures-content', target: "#manage-fixtures-content",
swap: 'outerHTML' swap: "outerHTML",
}
);
}
}
}));
}, },
})); );
},
},
}),
);
},
}),
);
}); });
</script> </script>
</div> </div>

View File

@@ -0,0 +1,168 @@
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 "fmt"
templ SeasonLeagueFreeAgentsPage(season *db.Season, league *db.League, freeAgents []*db.SeasonLeagueFreeAgent, availablePlayers []*db.Player) {
@SeasonLeagueLayout("free-agents", season, league) {
@SeasonLeagueFreeAgents(season, league, freeAgents, availablePlayers)
}
}
templ SeasonLeagueFreeAgents(season *db.Season, league *db.League, freeAgents []*db.SeasonLeagueFreeAgent, availablePlayers []*db.Player) {
{{
permCache := contexts.Permissions(ctx)
canAdd := permCache.HasPermission(permissions.FreeAgentsAdd)
canRemove := permCache.HasPermission(permissions.FreeAgentsRemove)
}}
<div x-data="{ showAddModal: false, selectedPlayerId: '' }">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-text">Free Agents ({ fmt.Sprint(len(freeAgents)) })</h2>
if canAdd {
<button
@click="showAddModal = true"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-green hover:bg-green/75 text-mantle transition"
>
Add Free Agent
</button>
}
</div>
if len(freeAgents) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No free agents registered in this league yet.</p>
if canAdd {
<p class="text-subtext1 text-sm mt-2">Click "Add Free Agent" to register a player.</p>
}
</div>
} else {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-mantle border-b border-surface1">
<tr>
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Player</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Registered By</th>
if canRemove {
<th class="px-4 py-3 text-right text-sm font-semibold text-text">Actions</th>
}
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for _, fa := range freeAgents {
<tr class="hover:bg-surface1 transition-colors">
<td class="px-4 py-3 text-sm text-text">
<span class="flex items-center gap-2">
{ fa.Player.DisplayName() }
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FREE AGENT
</span>
</span>
</td>
<td class="px-4 py-3 text-sm text-subtext0">
if fa.RegisteredBy != nil {
{ fa.RegisteredBy.Username }
}
</td>
if canRemove {
<td class="px-4 py-3 text-right">
<form
hx-post={ fmt.Sprintf("/seasons/%s/leagues/%s/free-agents/unregister", season.ShortName, league.ShortName) }
hx-swap="none"
class="inline"
>
<input type="hidden" name="player_id" value={ fmt.Sprint(fa.PlayerID) }/>
<button
type="submit"
class="px-3 py-1 text-xs bg-red/20 hover:bg-red/40 text-red rounded
transition hover:cursor-pointer"
>
Remove
</button>
</form>
</td>
}
</tr>
}
</tbody>
</table>
</div>
</div>
}
if canAdd {
@addFreeAgentModal(season, league, availablePlayers)
}
</div>
}
templ addFreeAgentModal(season *db.Season, league *db.League, availablePlayers []*db.Player) {
<div
x-show="showAddModal"
@keydown.escape.window="showAddModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
style="display: none;"
>
<!-- Backdrop -->
<div
class="fixed inset-0 bg-crust/80 transition-opacity"
@click="showAddModal = false"
></div>
<!-- Modal -->
<div class="flex min-h-full items-center justify-center p-4">
<div
class="relative bg-mantle border-2 border-surface1 rounded-xl shadow-xl max-w-md w-full p-6"
@click.stop
>
<h3 class="text-2xl font-bold text-text mb-4">Add Free Agent</h3>
<form
hx-post={ fmt.Sprintf("/seasons/%s/leagues/%s/free-agents/register", season.ShortName, league.ShortName) }
hx-swap="none"
>
if len(availablePlayers) == 0 {
<p class="text-subtext0 mb-4">No players available to register as free agents. All players are either on a team or already registered.</p>
} else {
<div class="mb-4">
<label for="player_id" class="block text-sm font-medium mb-2">Select Player</label>
<select
id="player_id"
name="player_id"
x-model="selectedPlayerId"
required
class="w-full py-3 px-4 rounded-lg text-sm bg-base border-2 border-overlay0
focus:border-blue outline-none"
>
<option value="">Choose a player...</option>
for _, player := range availablePlayers {
<option value={ fmt.Sprint(player.ID) }>
{ player.DisplayName() }
</option>
}
</select>
</div>
}
<div class="flex gap-3 justify-end">
<button
type="button"
@click="showAddModal = false"
class="px-4 py-2 rounded-lg bg-surface0 hover:bg-surface1 text-text transition hover:cursor-pointer"
>
Cancel
</button>
if len(availablePlayers) > 0 {
<button
type="submit"
:disabled="!selectedPlayerId"
class="px-4 py-2 rounded-lg bg-green hover:bg-green/75 text-mantle transition
disabled:bg-green/40 disabled:cursor-not-allowed hover:cursor-pointer"
>
Register Free Agent
</button>
}
</div>
</form>
</div>
</div>
</div>
}

View File

@@ -3,8 +3,15 @@ package seasonsview
import "git.haelnorr.com/h/oslstats/internal/view/baseview" import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/db"
import "fmt" import "fmt"
import "git.haelnorr.com/h/oslstats/internal/contexts"
import "git.haelnorr.com/h/oslstats/internal/permissions"
templ SeasonLeagueLayout(activeSection string, season *db.Season, league *db.League) { templ SeasonLeagueLayout(activeSection string, season *db.Season, league *db.League) {
{{
permCache := contexts.Permissions(ctx)
canEditSeason := permCache.HasPermission(permissions.SeasonsUpdate)
isDraftSeason := season.Type == db.SeasonTypeDraft.String()
}}
@baseview.Layout(fmt.Sprintf("%s - %s", season.Name, league.Name)) { @baseview.Layout(fmt.Sprintf("%s - %s", season.Name, league.Name)) {
<div class="max-w-screen-2xl mx-auto px-4 py-8"> <div class="max-w-screen-2xl mx-auto px-4 py-8">
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden"> <div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
@@ -12,19 +19,39 @@ templ SeasonLeagueLayout(activeSection string, season *db.Season, league *db.Lea
<div class="bg-surface0 border-b border-surface1 px-6 py-8"> <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 mb-4"> <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
<div> <div>
<h1 class="text-4xl font-bold text-text mb-2">{ season.Name } - { league.Name }</h1> <div class="flex items-center gap-3 mb-2">
if isDraftSeason {
<h1 class="text-4xl font-bold text-text">{ season.Name }</h1>
} else {
<h1 class="text-4xl font-bold text-text">{ season.Name } - { league.Name }</h1>
}
<span class="text-lg font-mono text-subtext0 bg-surface1 px-2 py-0.5 rounded">{ season.ShortName }</span>
</div>
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<span class="inline-block bg-surface1 px-3 py-1 rounded text-sm text-subtext0 font-mono"> @SeasonTypeBadge(season.Type)
{ season.ShortName }
</span>
<span class="inline-block bg-blue px-3 py-1 rounded-full text-sm font-semibold text-mantle">
{ league.ShortName }
</span>
@SlapVersionBadge(season.SlapVersion) @SlapVersionBadge(season.SlapVersion)
@StatusBadge(season, false, false) @StatusBadge(season, false, false)
</div> </div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
if isDraftSeason {
if canEditSeason {
<a
href={ templ.SafeURL("/seasons/" + season.ShortName + "/edit") }
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-blue hover:bg-blue/75 text-mantle transition"
>
Edit
</a>
}
<a
href="/seasons"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-surface1 hover:bg-surface2 text-text transition"
>
Back to Seasons
</a>
} else {
<a <a
href={ templ.SafeURL("/seasons/" + season.ShortName) } href={ templ.SafeURL("/seasons/" + season.ShortName) }
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
@@ -32,6 +59,7 @@ templ SeasonLeagueLayout(activeSection string, season *db.Season, league *db.Lea
> >
Back to Season Back to Season
</a> </a>
}
</div> </div>
</div> </div>
<!-- Season Dates --> <!-- Season Dates -->
@@ -92,6 +120,7 @@ templ SeasonLeagueLayout(activeSection string, season *db.Season, league *db.Lea
@leagueNavItem("table", "Table", activeSection, season, league) @leagueNavItem("table", "Table", activeSection, season, league)
@leagueNavItem("fixtures", "Fixtures", activeSection, season, league) @leagueNavItem("fixtures", "Fixtures", activeSection, season, league)
@leagueNavItem("teams", "Teams", activeSection, season, league) @leagueNavItem("teams", "Teams", activeSection, season, league)
@leagueNavItem("free-agents", "Free Agents", activeSection, season, league)
@leagueNavItem("stats", "Stats", activeSection, season, league) @leagueNavItem("stats", "Stats", activeSection, season, league)
@leagueNavItem("finals", "Finals", activeSection, season, league) @leagueNavItem("finals", "Finals", activeSection, season, league)
</ul> </ul>

View File

@@ -1,15 +1,115 @@
package seasonsview package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/db"
import "fmt"
templ SeasonLeagueTablePage(season *db.Season, league *db.League) { templ SeasonLeagueTablePage(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) {
@SeasonLeagueLayout("table", season, league) { @SeasonLeagueLayout("table", season, league) {
@SeasonLeagueTable() @SeasonLeagueTable(leaderboard)
} }
} }
templ SeasonLeagueTable() { templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) {
if len(leaderboard) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center"> <div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">Coming Soon...</p> <p class="text-subtext0 text-lg">No teams in this league yet.</p>
</div> </div>
} else {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
<!-- Scoring key -->
<div class="bg-mantle border-b border-surface1 px-4 py-2 flex items-center gap-4 text-xs text-subtext0">
<span class="font-semibold text-subtext1">Points:</span>
<span>W = { fmt.Sprint(db.PointsWin) }</span>
<span>OTW = { fmt.Sprint(db.PointsOvertimeWin) }</span>
<span>OTL = { fmt.Sprint(db.PointsOvertimeLoss) }</span>
<span>L = { fmt.Sprint(db.PointsLoss) }</span>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-mantle border-b border-surface1">
<tr>
<th class="px-3 py-3 text-center text-xs font-semibold text-subtext0 w-10">#</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-text">Team</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Games Played">GP</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Wins">W</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Overtime Wins">OTW</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Overtime Losses">OTL</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Losses">L</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Goals For">GF</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Goals Against">GA</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Goal Differential">GD</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-blue" title="Points">PTS</th>
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for _, entry := range leaderboard {
@leaderboardRow(entry)
}
</tbody>
</table>
</div>
</div>
}
}
templ leaderboardRow(entry *db.LeaderboardEntry) {
{{
r := entry.Record
goalDiff := r.GoalsFor - r.GoalsAgainst
var gdStr string
if goalDiff > 0 {
gdStr = fmt.Sprintf("+%d", goalDiff)
} else {
gdStr = fmt.Sprint(goalDiff)
}
}}
<tr class="hover:bg-surface1 transition-colors">
<td class="px-3 py-3 text-center text-sm font-medium text-subtext0">
{ fmt.Sprint(entry.Position) }
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
if entry.Team.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(entry.Team.Color) }
></span>
}
<span class="text-sm font-medium text-text">{ entry.Team.Name }</span>
</div>
</td>
<td class="px-3 py-3 text-center text-sm text-subtext0">
{ fmt.Sprint(r.Played) }
</td>
<td class="px-3 py-3 text-center text-sm text-green">
{ fmt.Sprint(r.Wins) }
</td>
<td class="px-3 py-3 text-center text-sm text-teal">
{ fmt.Sprint(r.OvertimeWins) }
</td>
<td class="px-3 py-3 text-center text-sm text-peach">
{ fmt.Sprint(r.OvertimeLosses) }
</td>
<td class="px-3 py-3 text-center text-sm text-red">
{ fmt.Sprint(r.Losses) }
</td>
<td class="px-3 py-3 text-center text-sm text-text">
{ fmt.Sprint(r.GoalsFor) }
</td>
<td class="px-3 py-3 text-center text-sm text-text">
{ fmt.Sprint(r.GoalsAgainst) }
</td>
<td class="px-3 py-3 text-center text-sm">
if goalDiff > 0 {
<span class="text-green">{ gdStr }</span>
} else if goalDiff < 0 {
<span class="text-red">{ gdStr }</span>
} else {
<span class="text-subtext0">{ gdStr }</span>
}
</td>
<td class="px-3 py-3 text-center text-sm font-bold text-blue">
{ fmt.Sprint(r.Points) }
</td>
</tr>
} }

View File

@@ -0,0 +1,641 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/permissions"
import "git.haelnorr.com/h/oslstats/internal/contexts"
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "fmt"
import "sort"
import "time"
templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult, record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) {
{{
team := twr.Team
season := twr.Season
league := twr.League
}}
@baseview.Layout(fmt.Sprintf("%s - %s - %s", team.Name, league.Name, season.Name)) {
<div class="max-w-screen-2xl mx-auto px-4 py-8">
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<!-- Header Section -->
<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 class="flex items-center gap-4">
if team.Color != "" {
<div
class="w-12 h-12 rounded-full border-2 border-surface1 shrink-0"
style={ "background-color: " + templ.SafeCSS(team.Color) }
></div>
}
<div>
<h1 class="text-4xl font-bold text-text">{ team.Name }</h1>
<div class="flex items-center gap-2 mt-2 flex-wrap">
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
{ team.ShortName }
</span>
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
{ team.AltShortName }
</span>
<span class="text-subtext1 text-sm">
{ season.Name } — { league.Name }
</span>
</div>
</div>
</div>
<a
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName)) }
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-surface1 hover:bg-surface2 text-text transition"
>
Back to Teams
</a>
</div>
</div>
<!-- Content -->
<div class="bg-crust p-6">
<!-- Top row: Roster (left) + Fixtures (right) -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
@TeamRosterSection(twr, available)
@teamFixturesPane(twr.Team, fixtures, scheduleMap, resultMap)
</div>
<!-- Stats below both -->
<div class="mt-6">
@teamStatsSection(record, playerStats)
</div>
</div>
</div>
</div>
<script src="/static/vendored/sortablejs@1.15.6.min.js"></script>
}
}
// TeamRosterSection renders the roster section — exported so it can be used for HTMX swaps
templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) {
{{
permCache := contexts.Permissions(ctx)
canManagePlayers := permCache.HasPermission(permissions.TeamsManagePlayers)
// Build the non-manager player list for display
rosterPlayers := []*db.Player{}
for _, p := range twr.Players {
if p != nil && (twr.Manager == nil || p.ID != twr.Manager.ID) {
rosterPlayers = append(rosterPlayers, p)
}
}
hasRoster := twr.Manager != nil || len(rosterPlayers) > 0
}}
<section
id="team-roster-section"
x-data="{ showManageRosterModal: false }"
>
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-text">Roster</h2>
if canManagePlayers {
<button
@click="showManageRosterModal = true"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-blue hover:bg-blue/80 text-mantle transition"
>
Manage Players
</button>
}
</div>
if !hasRoster {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No players on this roster yet.</p>
if canManagePlayers {
<p class="text-subtext1 text-sm mt-2">Click "Manage Players" to add players to this team.</p>
}
</div>
} else {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
if twr.Manager != nil {
<div class="px-4 py-3 flex items-center justify-between">
<span class="text-text font-medium">{ twr.Manager.Name }</span>
<span class="text-xs px-2 py-0.5 bg-yellow/20 text-yellow rounded font-medium">
&#9733; Manager
</span>
</div>
}
for _, player := range rosterPlayers {
<div class="px-4 py-3">
<span class="text-text">{ player.Name }</span>
</div>
}
</div>
}
if canManagePlayers {
@manageRosterModal(twr, available, rosterPlayers)
}
</section>
}
templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPlayers []*db.Player) {
<div
x-show="showManageRosterModal"
@keydown.escape.window="showManageRosterModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
style="display: none;"
x-data="rosterManager()"
x-init="$watch('showManageRosterModal', val => { if (val) $nextTick(() => init()) })"
>
<!-- Backdrop -->
<div
class="fixed inset-0 bg-crust/80 transition-opacity"
@click="showManageRosterModal = false"
></div>
<!-- Modal -->
<div class="flex min-h-full items-center justify-center p-4">
<div
class="relative bg-mantle border-2 border-surface1 rounded-xl shadow-xl max-w-4xl w-full p-6"
@click.stop
>
<div class="flex justify-between items-center mb-6">
<h3 class="text-2xl font-bold text-text">Manage Players</h3>
<button
@click="showManageRosterModal = false"
class="text-subtext0 hover:text-text transition hover:cursor-pointer"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Two column layout -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Left: Available Players -->
<div>
<h4 class="text-lg font-semibold text-text mb-3">Available Players</h4>
<input
type="text"
x-model="search"
@input="applySearch()"
placeholder="Search players..."
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
focus:border-blue focus:outline-none mb-3 text-sm"
/>
<div
id="available-players-list"
class="bg-base border border-surface1 rounded-lg p-2 min-h-48 max-h-80 overflow-y-auto space-y-1"
>
for _, player := range available {
<div
class="roster-player-chip px-3 py-2 bg-surface0 border border-surface1 rounded
text-text text-sm cursor-grab hover:bg-surface1 transition"
data-id={ fmt.Sprint(player.ID) }
data-name={ player.Name }
>
{ player.Name }
</div>
}
</div>
<p class="text-subtext1 text-xs mt-2">Drag players to the team roster</p>
</div>
<!-- Right: Team Roster -->
<div>
<h4 class="text-lg font-semibold text-text mb-3">Team Roster</h4>
<!-- Manager slot -->
<div class="mb-4">
<label class="text-sm font-medium text-yellow mb-2 flex items-center gap-1">
&#9733; Manager
</label>
<div
id="manager-drop-zone"
class="bg-base border-2 border-dashed border-yellow/40 rounded-lg p-2 min-h-12
flex items-center justify-center"
>
if twr.Manager != nil {
<div
class="roster-player-chip px-3 py-2 bg-yellow/10 border border-yellow/30 rounded
text-text text-sm cursor-grab w-full"
data-id={ fmt.Sprint(twr.Manager.ID) }
data-name={ twr.Manager.Name }
>
{ twr.Manager.Name }
</div>
}
</div>
</div>
<!-- Players list -->
<div>
<label class="text-sm font-medium text-subtext0 mb-2 block">Players</label>
<div
id="roster-drop-zone"
class="bg-base border-2 border-dashed border-surface1 rounded-lg p-2 min-h-40
max-h-60 overflow-y-auto space-y-1"
>
for _, player := range rosterPlayers {
<div
class="roster-player-chip px-3 py-2 bg-surface0 border border-surface1 rounded
text-text text-sm cursor-grab hover:bg-surface1 transition"
data-id={ fmt.Sprint(player.ID) }
data-name={ player.Name }
>
{ player.Name }
</div>
}
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex justify-between items-center mt-6 pt-4 border-t border-surface1">
<p
x-show="!hasManager"
class="text-sm text-red"
>
A manager is required
</p>
<div class="flex gap-3 ml-auto">
<button
type="button"
@click="showManageRosterModal = false"
class="px-4 py-2 rounded-lg bg-surface0 hover:bg-surface1 text-text transition hover:cursor-pointer"
>
Cancel
</button>
<form
id="roster-submit-form"
hx-post="/teams/manage_roster"
hx-swap="none"
class="inline"
>
<input type="hidden" name="season_id" value={ fmt.Sprint(twr.Season.ID) }/>
<input type="hidden" name="league_id" value={ fmt.Sprint(twr.League.ID) }/>
<input type="hidden" name="team_id" value={ fmt.Sprint(twr.Team.ID) }/>
<input type="hidden" name="manager_id" value="0"/>
<!-- player_ids inputs are added dynamically by submitRoster() -->
<button
type="button"
@click="submitRoster()"
:disabled="!hasManager"
class="px-4 py-2 rounded-lg text-mantle transition"
:class="hasManager ? 'bg-green hover:bg-green/75 hover:cursor-pointer' : 'bg-green/40 cursor-not-allowed'"
>
Save Roster
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
function rosterManager() {
return {
search: '',
hasManager: false,
sortableInstances: [],
updateHasManager() {
const zone = document.getElementById('manager-drop-zone');
this.hasManager = zone ? zone.querySelectorAll('.roster-player-chip').length > 0 : false;
},
init() {
if (typeof Sortable === 'undefined') return;
// Destroy any previous instances
this.sortableInstances.forEach(s => s.destroy());
this.sortableInstances = [];
const self = this;
this.sortableInstances.push(new Sortable(
document.getElementById('available-players-list'), {
group: { name: 'roster', pull: true, put: true },
sort: false,
animation: 150,
onAdd(evt) { self.applySearch(); self.updateHasManager(); },
onRemove(evt) { self.applySearch(); self.updateHasManager(); }
}));
this.sortableInstances.push(new Sortable(
document.getElementById('manager-drop-zone'), {
group: { name: 'roster', pull: true, put: function(to) {
return to.el.querySelectorAll('.roster-player-chip').length === 0;
}},
sort: false,
animation: 150,
onAdd(evt) {
// Style the chip for manager zone
evt.item.classList.remove('bg-surface0', 'border-surface1');
evt.item.classList.add('bg-yellow/10', 'border-yellow/30', 'w-full');
self.updateHasManager();
},
onRemove(evt) {
// Revert style
evt.item.classList.add('bg-surface0', 'border-surface1');
evt.item.classList.remove('bg-yellow/10', 'border-yellow/30', 'w-full');
self.updateHasManager();
}
}));
this.sortableInstances.push(new Sortable(
document.getElementById('roster-drop-zone'), {
group: { name: 'roster', pull: true, put: true },
sort: true,
animation: 150,
onAdd(evt) {
// Ensure surface styling
evt.item.classList.add('bg-surface0', 'border-surface1');
evt.item.classList.remove('bg-yellow/10', 'border-yellow/30', 'w-full');
}
}));
this.updateHasManager();
this.applySearch();
},
applySearch() {
const s = this.search.toLowerCase();
const list = document.getElementById('available-players-list');
if (!list) return;
const chips = list.querySelectorAll('.roster-player-chip');
chips.forEach(chip => {
const name = (chip.dataset.name || '').toLowerCase();
if (s === '' || name.includes(s)) {
chip.style.display = '';
} else {
chip.style.display = 'none';
}
});
},
submitRoster() {
if (!this.hasManager) return;
const managerZone = document.getElementById('manager-drop-zone');
const rosterZone = document.getElementById('roster-drop-zone');
const managerChip = managerZone ? managerZone.querySelector('.roster-player-chip') : null;
const managerID = managerChip ? managerChip.dataset.id : '0';
const rosterChips = rosterZone ? rosterZone.querySelectorAll('.roster-player-chip') : [];
const playerIDs = Array.from(rosterChips).map(el => el.dataset.id);
// Build a hidden form dynamically and submit via HTMX
const form = document.getElementById('roster-submit-form');
// Clear previous dynamic inputs
form.querySelectorAll('.dynamic-input').forEach(el => el.remove());
// Set manager ID
form.querySelector('[name="manager_id"]').value = managerID;
// Add player_ids as multiple hidden inputs
playerIDs.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'player_ids';
input.value = id;
input.className = 'dynamic-input';
form.appendChild(input);
});
// Trigger HTMX request on the form
htmx.trigger(form, 'submit');
}
}
}
</script>
}
templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) {
{{
// Split fixtures into upcoming and completed
var upcoming []*db.Fixture
var completed []*db.Fixture
for _, f := range fixtures {
if _, hasResult := resultMap[f.ID]; hasResult {
completed = append(completed, f)
} else {
upcoming = append(upcoming, f)
}
}
// Sort completed by scheduled time descending (most recent first)
sort.Slice(completed, func(i, j int) bool {
ti := time.Time{}
tj := time.Time{}
if si, ok := scheduleMap[completed[i].ID]; ok && si.ScheduledTime != nil {
ti = *si.ScheduledTime
}
if sj, ok := scheduleMap[completed[j].ID]; ok && sj.ScheduledTime != nil {
tj = *sj.ScheduledTime
}
return ti.After(tj)
})
// Limit to 5 most recent results
recentResults := completed
if len(recentResults) > 5 {
recentResults = recentResults[:5]
}
}}
<section class="space-y-6">
<!-- Results -->
<div>
<h2 class="text-2xl font-bold text-text mb-4">Results</h2>
if len(recentResults) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No results yet.</p>
<p class="text-subtext1 text-sm mt-2">Match results will appear here once games are played.</p>
</div>
} else {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
for _, fixture := range recentResults {
@teamResultRow(team, fixture, resultMap)
}
</div>
}
</div>
<!-- Upcoming -->
<div>
<h2 class="text-2xl font-bold text-text mb-4">Upcoming</h2>
if len(upcoming) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No upcoming fixtures.</p>
</div>
} else {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
for _, fixture := range upcoming {
@teamFixtureRow(team, fixture, scheduleMap)
}
</div>
}
</div>
</section>
}
templ teamFixtureRow(team *db.Team, fixture *db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
{{
isHome := fixture.HomeTeamID == team.ID
var opponent string
if isHome {
opponent = fixture.AwayTeam.Name
} else {
opponent = fixture.HomeTeam.Name
}
sched, hasSchedule := scheduleMap[fixture.ID]
_ = sched
}}
<a
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
class="px-4 py-3 flex items-center justify-between gap-3 hover:bg-surface1 transition hover:cursor-pointer block"
>
<div class="flex items-center gap-3 min-w-0">
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded shrink-0">
GW{ fmt.Sprint(*fixture.GameWeek) }
</span>
if isHome {
<span class="text-xs px-2 py-0.5 bg-blue/20 text-blue rounded font-medium shrink-0">
HOME
</span>
} else {
<span class="text-xs px-2 py-0.5 bg-surface1 text-subtext0 rounded font-medium shrink-0">
AWAY
</span>
}
<span class="text-sm text-subtext0 shrink-0">vs</span>
<span class="text-text font-medium truncate">
{ opponent }
</span>
</div>
if hasSchedule && sched.ScheduledTime != nil {
<span class="text-xs text-green font-medium shrink-0">
@localtime(sched.ScheduledTime, "short")
</span>
} else {
<span class="text-xs text-subtext1 shrink-0">
TBD
</span>
}
</a>
}
templ teamResultRow(team *db.Team, fixture *db.Fixture, resultMap map[int]*db.FixtureResult) {
{{
isHome := fixture.HomeTeamID == team.ID
var opponent string
if isHome {
opponent = fixture.AwayTeam.Name
} else {
opponent = fixture.HomeTeam.Name
}
res := resultMap[fixture.ID]
won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away")
lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home")
_ = lost
}}
<a
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
class="px-4 py-3 flex items-center justify-between gap-3 hover:bg-surface1 transition hover:cursor-pointer block"
>
<div class="flex items-center gap-3 min-w-0">
if won {
<span class="text-xs font-bold px-2 py-0.5 bg-green/20 text-green rounded shrink-0">W</span>
} else if lost {
<span class="text-xs font-bold px-2 py-0.5 bg-red/20 text-red rounded shrink-0">L</span>
} else {
<span class="text-xs font-bold px-2 py-0.5 bg-surface1 text-subtext0 rounded shrink-0">D</span>
}
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded shrink-0">
GW{ fmt.Sprint(*fixture.GameWeek) }
</span>
if isHome {
<span class="text-xs px-2 py-0.5 bg-blue/20 text-blue rounded font-medium shrink-0">
HOME
</span>
} else {
<span class="text-xs px-2 py-0.5 bg-surface1 text-subtext0 rounded font-medium shrink-0">
AWAY
</span>
}
<span class="text-sm text-subtext0 shrink-0">vs</span>
<span class="text-text font-medium truncate">
{ opponent }
</span>
</div>
<span class="flex items-center gap-2 shrink-0">
if res.Winner == "home" {
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
} else if res.Winner == "away" {
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
} else {
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
<span class="text-xs text-subtext0"></span>
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
}
</span>
</a>
}
templ teamStatsSection(record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) {
<section>
<div class="mb-4">
<h2 class="text-2xl font-bold text-text">Stats</h2>
</div>
if record.Played == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No stats yet.</p>
<p class="text-subtext1 text-sm mt-2">Team statistics will appear here once games are played.</p>
</div>
} else {
<!-- Team Record Summary -->
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden mb-4">
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 divide-x divide-surface1">
@statCell("Played", fmt.Sprint(record.Played), "")
@statCell("Record", fmt.Sprintf("%d-%d-%d", record.Wins, record.Losses, record.Draws), "")
@statCell("Wins", fmt.Sprint(record.Wins), "text-green")
@statCell("Losses", fmt.Sprint(record.Losses), "text-red")
@statCell("GF", fmt.Sprint(record.GoalsFor), "")
@statCell("GA", fmt.Sprint(record.GoalsAgainst), "")
</div>
</div>
<!-- Player Stats Leaderboard -->
if len(playerStats) > 0 {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-mantle 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="Games Played">GP</th>
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</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>
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for _, ps := range playerStats {
<tr class="hover:bg-surface1 transition-colors">
<td class="px-3 py-2 text-sm text-text">{ ps.PlayerName }</td>
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.GamesPlayed) }</td>
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ fmt.Sprint(ps.Score) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Goals) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Assists) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Saves) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Shots) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Blocks) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Passes) }</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</section>
}
templ statCell(label string, value string, valueColor string) {
<div class="px-4 py-3 text-center">
<p class="text-xs text-subtext0 font-medium uppercase mb-1">{ label }</p>
<p class={ "text-lg font-bold", templ.KV("text-text", valueColor == ""), templ.KV(valueColor, valueColor != "") }>
{ value }
</p>
</div>
}

View File

@@ -5,9 +5,9 @@ import "git.haelnorr.com/h/oslstats/internal/permissions"
import "git.haelnorr.com/h/oslstats/internal/contexts" import "git.haelnorr.com/h/oslstats/internal/contexts"
import "fmt" import "fmt"
templ SeasonLeagueTeamsPage(season *db.Season, league *db.League, teams []*db.Team, allTeams []*db.Team) { templ SeasonLeagueTeamsPage(season *db.Season, league *db.League, teams []*db.Team, allTeams []*db.Team, managers map[int]*db.Player) {
@SeasonLeagueLayout("teams", season, league) { @SeasonLeagueLayout("teams", season, league) {
@SeasonLeagueTeams(season, league, teams, allTeams) @SeasonLeagueTeams(season, league, teams, allTeams, managers)
} }
} }
@@ -94,7 +94,7 @@ templ addTeamModal(season *db.Season, league *db.League, existingTeams []*db.Tea
</div> </div>
} }
templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team, allTeams []*db.Team) { templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team, allTeams []*db.Team, managers map[int]*db.Player) {
{{ {{
permCache := contexts.Permissions(ctx) permCache := contexts.Permissions(ctx)
canAddTeam := permCache.HasPermission(permissions.TeamsAddToLeague) canAddTeam := permCache.HasPermission(permissions.TeamsAddToLeague)
@@ -122,7 +122,13 @@ templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team,
} else { } else {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, team := range teams { for _, team := range teams {
<div class="bg-surface0 border border-surface1 rounded-lg p-6 hover:bg-surface1 transition-colors"> {{
manager := managers[team.ID]
}}
<a
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams/%d", season.ShortName, league.ShortName, team.ID)) }
class="bg-surface0 border border-surface1 rounded-lg p-6 hover:bg-surface1 transition-colors hover:cursor-pointer block"
>
<div class="flex justify-between items-start mb-3"> <div class="flex justify-between items-start mb-3">
<h3 class="text-xl font-bold text-text">{ team.Name }</h3> <h3 class="text-xl font-bold text-text">{ team.Name }</h3>
if team.Color != "" { if team.Color != "" {
@@ -132,7 +138,7 @@ templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team,
></div> ></div>
} }
</div> </div>
<div class="flex items-center gap-2 text-sm flex-wrap"> <div class="flex items-center gap-2 text-sm flex-wrap mb-3">
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono"> <span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono">
{ team.ShortName } { team.ShortName }
</span> </span>
@@ -140,8 +146,30 @@ templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team,
{ team.AltShortName } { team.AltShortName }
</span> </span>
</div> </div>
<!-- Roster -->
if len(team.Players) == 0 {
<p class="text-subtext1 text-xs italic">No roster</p>
} else {
<div class="border-t border-surface1 pt-3 space-y-1">
if manager != nil {
<div class="flex items-center justify-between">
<span class="text-sm text-text font-medium">{ manager.Name }</span>
<span class="text-xs px-1.5 py-0.5 bg-yellow/20 text-yellow rounded font-medium">
&#9733; Mgr
</span>
</div> </div>
} }
for _, player := range team.Players {
if manager == nil || player.ID != manager.ID {
<div>
<span class="text-sm text-subtext0">{ player.Name }</span>
</div>
}
}
</div>
}
</a>
}
</div> </div>
} }
if canAddTeam { if canAddTeam {

View File

@@ -0,0 +1,7 @@
package seasonsview
// FreeAgentWarning holds information about a free agent nomination issue for display.
type FreeAgentWarning struct {
Name string
Reason string
}

37
pkg/slapshotapi/client.go Normal file
View File

@@ -0,0 +1,37 @@
package slapshotapi
import (
"net/http"
"sync"
"github.com/pkg/errors"
"golang.org/x/time/rate"
)
type SlapAPI struct {
client *http.Client
ratelimiter *rate.Limiter
mu sync.Mutex
maxTokens int
key string
env string
}
func NewSlapAPIClient(cfg *Config) (*SlapAPI, error) {
if cfg == nil {
return nil, errors.New("config cannot be nil")
}
if cfg.Environment != "api" && cfg.Environment != "staging" {
return nil, errors.New("invalid env specified, must be 'api' or 'staging'")
}
rl := rate.NewLimiter(rate.Inf, 10)
client := &SlapAPI{
client: http.DefaultClient,
ratelimiter: rl,
mu: sync.Mutex{},
maxTokens: 10,
key: cfg.Key,
env: cfg.Environment,
}
return client, nil
}

23
pkg/slapshotapi/config.go Normal file
View File

@@ -0,0 +1,23 @@
// Package slapshotapi provides utilities for interacting with the slapshot public API
package slapshotapi
import (
"git.haelnorr.com/h/golib/env"
"github.com/pkg/errors"
)
type Config struct {
Environment string // ENV SLAPSHOT_ENVIRONMENT: API environment to connect to (default: staging)
Key string // ENV SLAPSHOT_API_KEY: API Key for authorisation with the API (required)
}
func ConfigFromEnv() (any, error) {
cfg := &Config{
Environment: env.String("SLAPSHOT_ENVIRONMENT", "staging"),
Key: env.String("SLAPSHOT_API_KEY", ""),
}
if cfg.Key == "" {
return nil, errors.New("Envar not set: SLAPSHOT_API_KEY")
}
return cfg, nil
}

79
pkg/slapshotapi/enums.go Normal file
View File

@@ -0,0 +1,79 @@
package slapshotapi
// Regions
const (
RegionEUWest = "eu-west"
RegionNAEast = "na-east"
RegionNACentral = "na-central"
RegionNAWest = "na-west"
RegionOCEEast = "oce-east"
)
// Arenas - API format (used in API responses and lobby creation)
const (
ArenaSlapstadium = "Slapstadium"
ArenaSlapville = "Slapville"
ArenaSlapstadiumMini = "Slapstadium_mini"
ArenaTableHockey = "Table_Hockey"
ArenaColosseum = "Colosseum"
ArenaSlapvilleJumbo = "Slapville_Jumbo"
ArenaSlapstation = "Slapstation"
ArenaSlapstadiumXL = "Slapstadium_XL"
ArenaIsland = "Island"
ArenaObstacles = "Obstacles"
ArenaObstaclesXL = "Obstacles_XL"
ArenaCyberpuck = "Cyberpuck"
)
// Arenas - Display format (used in local match logs)
const (
ArenaDisplaySlapStadium = "Slap Stadium"
ArenaDisplaySlapville = "Slapville"
ArenaDisplaySlapStadiumMini = "Slap Stadium Mini"
ArenaDisplayTableHockey = "Table Hockey"
ArenaDisplayColosseum = "Colosseum"
ArenaDisplaySlapvilleJumbo = "Slapville Jumbo"
ArenaDisplaySlapstadiumXL = "Slapstadium XL"
ArenaDisplayIsland = "Island"
ArenaDisplayObstaclesXL = "Obstacles XL"
ArenaDisplayCyberpuck = "Cyberpuck"
)
// End reasons
const (
EndReasonEndOfReg = "EndOfRegulation"
EndReasonOvertime = "Overtime"
EndReasonHomeTeamLeft = "HomeTeamLeft"
EndReasonAwayTeamLeft = "AwayTeamLeft"
EndReasonMercy = "MercyRule"
EndReasonTie = "Tie"
EndReasonForfeit = "Forfeit"
EndReasonCancelled = "Cancelled"
EndReasonUnknown = "Unknown"
)
// Game modes
const (
GameModeHockey = "hockey"
GameModeDodgePuck = "dodgepuck"
GameModeTag = "tag"
)
// Teams
const (
TeamHome = "home"
TeamAway = "away"
)
// Match winners
const (
WinnerHome = "home"
WinnerAway = "away"
WinnerNone = "none"
)
// Match types
const (
MatchTypePublic = "public"
MatchTypePrivate = "private"
)

41
pkg/slapshotapi/ezconf.go Normal file
View File

@@ -0,0 +1,41 @@
package slapshotapi
import (
"runtime"
"strings"
)
// EZConfIntegration provides integration with ezconf for automatic configuration
type EZConfIntegration struct {
configFunc func() (any, error)
name string
}
// PackagePath returns the path to the config package for source parsing
func (e EZConfIntegration) PackagePath() string {
_, filename, _, _ := runtime.Caller(0)
// Return directory of this file
return filename[:len(filename)-len("/ezconf.go")]
}
// ConfigFunc returns the ConfigFromEnv function for ezconf
func (e EZConfIntegration) ConfigFunc() func() (any, error) {
return func() (any, error) {
return e.configFunc()
}
}
// Name returns the name to use when registering with ezconf
func (e EZConfIntegration) Name() string {
return strings.ToLower(e.name)
}
// GroupName returns the display name for grouping environment variables
func (e EZConfIntegration) GroupName() string {
return e.name
}
// NewEZConfIntegration creates a new EZConf integration helper
func NewEZConfIntegration() EZConfIntegration {
return EZConfIntegration{name: "SlapshotAPI", configFunc: ConfigFromEnv}
}

53
pkg/slapshotapi/games.go Normal file
View File

@@ -0,0 +1,53 @@
package slapshotapi
import (
"context"
"fmt"
"strings"
"github.com/pkg/errors"
)
type endpointGame struct {
gameID string
}
func getEndpointGame(gameID string) *endpointGame {
return &endpointGame{
gameID: gameID,
}
}
func (ep *endpointGame) path() string {
return fmt.Sprintf("/api/public/games/%s", ep.gameID)
}
func (ep *endpointGame) method() string {
return "GET"
}
// ErrGameNotFound is returned when a game ID does not match any known game
var ErrGameNotFound = errors.New("game not found")
// GetGame retrieves match details for a specific game by its ID.
func (c *SlapAPI) GetGame(
ctx context.Context,
gameID string,
) (*Game, error) {
if gameID == "" {
return nil, errors.New("gameID cannot be empty")
}
endpoint := getEndpointGame(gameID)
data, err := c.request(ctx, endpoint)
if err != nil {
if strings.Contains(err.Error(), "404") {
return nil, ErrGameNotFound
}
return nil, errors.Wrap(err, "c.request")
}
game, err := unmarshal[Game](data)
if err != nil {
return nil, errors.Wrap(err, "unmarshal")
}
return game, nil
}

View File

@@ -0,0 +1,59 @@
package slapshotapi
import (
"context"
"net/http"
"strconv"
"time"
"github.com/pkg/errors"
"golang.org/x/time/rate"
)
func (c *SlapAPI) do(ctx context.Context, req *http.Request) (*http.Response, error) {
for {
err := c.ratelimiter.Wait(ctx)
if err != nil {
return nil, errors.Wrap(err, "c.ratelimiter.Wait")
}
resp, err := c.client.Do(req)
if err != nil {
return nil, errors.Wrap(err, "c.client.Do")
}
if resp.StatusCode == http.StatusTooManyRequests {
resetAfter := 30 * time.Second
err := resp.Body.Close()
if err != nil {
return nil, errors.Wrap(err, "resp.Body.Close")
}
if resetAfter > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(resetAfter):
continue
}
}
}
c.updateLimiterFromHeaders(resp.Header)
return resp, nil
}
}
func (c *SlapAPI) updateLimiterFromHeaders(h http.Header) {
c.mu.Lock()
defer c.mu.Unlock()
limit, err1 := strconv.Atoi(h.Get("RateLimit-Limit"))
window, err2 := strconv.Atoi(h.Get("RateLimit-Window"))
if err1 != nil || err2 != nil || limit <= 0 || window <= 0 {
return
}
if limit != c.maxTokens || time.Duration(window) != time.Duration(float64(window)/float64(limit))*time.Second {
c.maxTokens = limit
c.ratelimiter.SetBurst(limit)
c.ratelimiter.SetLimit(rate.Every(time.Duration(window) / time.Duration(limit)))
}
}

187
pkg/slapshotapi/lobbies.go Normal file
View File

@@ -0,0 +1,187 @@
package slapshotapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/pkg/errors"
)
// --- Get Lobby ---
type endpointGetLobby struct {
lobbyID string
}
func getEndpointGetLobby(lobbyID string) *endpointGetLobby {
return &endpointGetLobby{lobbyID: lobbyID}
}
func (ep *endpointGetLobby) path() string {
return fmt.Sprintf("/api/public/lobbies/%s", ep.lobbyID)
}
func (ep *endpointGetLobby) method() string {
return "GET"
}
// ErrLobbyNotFound is returned when a lobby ID does not match any known lobby
var ErrLobbyNotFound = errors.New("lobby not found")
// GetLobby retrieves details for a specific lobby by its ID.
func (c *SlapAPI) GetLobby(
ctx context.Context,
lobbyID string,
) (*Lobby, error) {
if lobbyID == "" {
return nil, errors.New("lobbyID cannot be empty")
}
endpoint := getEndpointGetLobby(lobbyID)
data, err := c.request(ctx, endpoint)
if err != nil {
if strings.Contains(err.Error(), "404") {
return nil, ErrLobbyNotFound
}
return nil, errors.Wrap(err, "c.request")
}
lobby, err := unmarshal[Lobby](data)
if err != nil {
return nil, errors.Wrap(err, "unmarshal")
}
return lobby, nil
}
// --- Get Lobby Matches ---
type endpointGetLobbyMatches struct {
lobbyID string
}
func getEndpointGetLobbyMatches(lobbyID string) *endpointGetLobbyMatches {
return &endpointGetLobbyMatches{lobbyID: lobbyID}
}
func (ep *endpointGetLobbyMatches) path() string {
return fmt.Sprintf("/api/public/lobbies/%s/matches", ep.lobbyID)
}
func (ep *endpointGetLobbyMatches) method() string {
return "GET"
}
// GetLobbyMatches retrieves the list of matches played in a specific lobby.
func (c *SlapAPI) GetLobbyMatches(
ctx context.Context,
lobbyID string,
) ([]Game, error) {
if lobbyID == "" {
return nil, errors.New("lobbyID cannot be empty")
}
endpoint := getEndpointGetLobbyMatches(lobbyID)
data, err := c.request(ctx, endpoint)
if err != nil {
if strings.Contains(err.Error(), "404") {
return nil, ErrLobbyNotFound
}
return nil, errors.Wrap(err, "c.request")
}
var games []Game
err = json.Unmarshal(data, &games)
if err != nil {
return nil, errors.Wrap(err, "json.Unmarshal")
}
return games, nil
}
// --- Create Lobby ---
type endpointCreateLobby struct {
request LobbyCreationRequest
}
func getEndpointCreateLobby(req LobbyCreationRequest) *endpointCreateLobby {
return &endpointCreateLobby{request: req}
}
func (ep *endpointCreateLobby) path() string {
return "/api/public/lobbies"
}
func (ep *endpointCreateLobby) method() string {
return "POST"
}
func (ep *endpointCreateLobby) body() ([]byte, error) {
data, err := json.Marshal(ep.request)
if err != nil {
return nil, errors.Wrap(err, "json.Marshal")
}
return data, nil
}
// CreateLobby creates a new custom lobby with the specified settings.
func (c *SlapAPI) CreateLobby(
ctx context.Context,
req LobbyCreationRequest,
) (*LobbyCreationResponse, error) {
if req.Region == "" {
return nil, errors.New("region cannot be empty")
}
if req.Name == "" {
return nil, errors.New("name cannot be empty")
}
if req.CreatorName == "" {
return nil, errors.New("creator_name cannot be empty")
}
endpoint := getEndpointCreateLobby(req)
data, err := c.request(ctx, endpoint)
if err != nil {
return nil, errors.Wrap(err, "c.request")
}
resp, err := unmarshal[LobbyCreationResponse](data)
if err != nil {
return nil, errors.Wrap(err, "unmarshal")
}
return resp, nil
}
// --- Delete Lobby ---
type endpointDeleteLobby struct {
lobbyID string
}
func getEndpointDeleteLobby(lobbyID string) *endpointDeleteLobby {
return &endpointDeleteLobby{lobbyID: lobbyID}
}
func (ep *endpointDeleteLobby) path() string {
return fmt.Sprintf("/api/public/lobbies/%s", ep.lobbyID)
}
func (ep *endpointDeleteLobby) method() string {
return "DELETE"
}
// DeleteLobby deletes an existing lobby by its ID.
// Returns true if the lobby was successfully deleted.
func (c *SlapAPI) DeleteLobby(
ctx context.Context,
lobbyID string,
) (bool, error) {
if lobbyID == "" {
return false, errors.New("lobbyID cannot be empty")
}
endpoint := getEndpointDeleteLobby(lobbyID)
status, body, err := c.requestRaw(ctx, endpoint)
if err != nil {
return false, errors.Wrap(err, "c.requestRaw")
}
if status == http.StatusNotFound {
return false, ErrLobbyNotFound
}
return string(body) == "OK", nil
}

View File

@@ -0,0 +1,64 @@
package slapshotapi
import (
"github.com/pkg/errors"
)
// MatchLog represents the raw JSON format of a local match log file.
// This differs slightly from the API Game response in structure — the
// match log is a flat object with game stats at the top level, whereas
// the API response wraps game stats inside a nested "game_stats" field
// alongside metadata like region, match type, and creation time.
type MatchLog struct {
Type string `json:"type"`
MatchID string `json:"match_id,omitempty"`
Winner string `json:"winner"`
Arena string `json:"arena"`
PeriodsEnabled string `json:"periods_enabled"`
CurrentPeriod string `json:"current_period"`
CustomMercyRule string `json:"custom_mercy_rule"`
MatchLength string `json:"match_length"`
EndReason string `json:"end_reason"`
Score Score `json:"score"`
Players []Player `json:"players"`
}
// ParseMatchLog parses a local match log JSON file into a MatchLog struct.
func ParseMatchLog(data []byte) (*MatchLog, error) {
if len(data) == 0 {
return nil, errors.New("data cannot be empty")
}
log, err := unmarshal[MatchLog](data)
if err != nil {
return nil, errors.Wrap(err, "unmarshal")
}
return log, nil
}
// ToGameStats converts a MatchLog into a GameStats struct, normalizing the
// local match log format into the same structure used by the API.
func (ml *MatchLog) ToGameStats() GameStats {
return GameStats{
Type: ml.Type,
Arena: ml.Arena,
Score: ml.Score,
Winner: ml.Winner,
EndReason: ml.EndReason,
MatchLength: ml.MatchLength,
PeriodsEnabled: ml.PeriodsEnabled,
CurrentPeriod: ml.CurrentPeriod,
CustomMercyRule: ml.CustomMercyRule,
Players: ml.Players,
}
}
// ToGame converts a MatchLog into a Game struct. Since match logs don't
// contain all fields present in an API response (region, match_type,
// gamemode, created), those fields will be empty. The match_id from the
// log (if present) is used as the Game ID.
func (ml *MatchLog) ToGame() Game {
return Game{
ID: ml.MatchID,
GameStats: ml.ToGameStats(),
}
}

View File

@@ -0,0 +1,63 @@
package slapshotapi
import (
"context"
"fmt"
"github.com/pkg/errors"
)
type endpointMatchmaking struct {
regions []string
}
func getEndpointMatchmaking(regions []string) *endpointMatchmaking {
return &endpointMatchmaking{
regions: regions,
}
}
func (ep *endpointMatchmaking) path() string {
path := "/api/public/matchmaking%s"
filters := ""
if len(ep.regions) > 0 {
filters = "?regions="
for i, region := range ep.regions {
filters = filters + region
if i+1 != len(ep.regions) {
filters = filters + ","
}
}
}
return fmt.Sprintf(path, filters)
}
func (ep *endpointMatchmaking) method() string {
return "GET"
}
type matchmakingresp struct {
Playlists PubsQueue `json:"playlists"`
}
type PubsQueue struct {
InQueue uint16 `json:"in_queue"`
InMatch uint16 `json:"in_match"`
}
// GetQueueStatus gets the number of players in public matchmaking
func (c *SlapAPI) GetQueueStatus(
ctx context.Context,
regions []string,
) (*PubsQueue, error) {
endpoint := getEndpointMatchmaking(regions)
data, err := c.request(ctx, endpoint)
if err != nil {
return nil, errors.Wrap(err, "c.request")
}
resp, err := unmarshal[matchmakingresp](data)
if err != nil {
return nil, errors.Wrap(err, "unmarshal")
}
return &resp.Playlists, nil
}

116
pkg/slapshotapi/request.go Normal file
View File

@@ -0,0 +1,116 @@
package slapshotapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/pkg/errors"
)
type endpoint interface {
path() string
method() string
}
// bodyEndpoint is an optional interface for endpoints that send a JSON body
type bodyEndpoint interface {
endpoint
body() ([]byte, error)
}
func (c *SlapAPI) request(
ctx context.Context,
ep endpoint,
) ([]byte, error) {
baseurl := fmt.Sprintf("https://%s.slapshot.gg%s", c.env, ep.path())
var bodyReader io.Reader
if bep, ok := ep.(bodyEndpoint); ok {
data, err := bep.body()
if err != nil {
return nil, errors.Wrap(err, "endpoint.body")
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, ep.method(), baseurl, bodyReader)
if err != nil {
return nil, errors.Wrap(err, "http.NewRequestWithContext")
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.key))
if bodyReader != nil {
req.Header.Add("Content-Type", "application/json")
}
res, err := c.do(ctx, req)
if err != nil {
return nil, errors.Wrap(err, "c.do")
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
return nil, errors.New(fmt.Sprintf("API request failed with status %d", res.StatusCode))
}
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, errors.Wrap(err, "io.ReadAll")
}
return body, nil
}
// requestRaw performs an API request and returns the raw status code and body.
// This is used for endpoints where non-200 status codes carry meaningful data
// (e.g. DELETE returning a plain text response).
func (c *SlapAPI) requestRaw(
ctx context.Context,
ep endpoint,
) (int, []byte, error) {
baseurl := fmt.Sprintf("https://%s.slapshot.gg%s", c.env, ep.path())
var bodyReader io.Reader
if bep, ok := ep.(bodyEndpoint); ok {
data, err := bep.body()
if err != nil {
return 0, nil, errors.Wrap(err, "endpoint.body")
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, ep.method(), baseurl, bodyReader)
if err != nil {
return 0, nil, errors.Wrap(err, "http.NewRequestWithContext")
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.key))
if bodyReader != nil {
req.Header.Add("Content-Type", "application/json")
}
res, err := c.do(ctx, req)
if err != nil {
return 0, nil, errors.Wrap(err, "c.do")
}
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
if err != nil {
return 0, nil, errors.Wrap(err, "io.ReadAll")
}
return res.StatusCode, body, nil
}
// unmarshal is a helper that unmarshals JSON response data into a target struct
func unmarshal[T any](data []byte) (*T, error) {
var result T
err := json.Unmarshal(data, &result)
if err != nil {
return nil, errors.Wrap(err, "json.Unmarshal")
}
return &result, nil
}

53
pkg/slapshotapi/slapid.go Normal file
View File

@@ -0,0 +1,53 @@
package slapshotapi
import (
"context"
"fmt"
"strings"
"github.com/pkg/errors"
)
type endpointSteamID struct {
steamID string
}
func getEndpointSteamID(steamID string) *endpointSteamID {
return &endpointSteamID{
steamID: steamID,
}
}
func (ep *endpointSteamID) path() string {
return fmt.Sprintf("/api/public/players/steam/%s", ep.steamID)
}
func (ep *endpointSteamID) method() string {
return "GET"
}
type idresp struct {
ID uint32 `json:"id"`
}
var ErrNoSlapID error = errors.New("slapID not found")
// GetSlapID returns the slapshot ID of the steam user.
func (c *SlapAPI) GetSlapID(
ctx context.Context,
steamid string,
) (uint32, error) {
endpoint := getEndpointSteamID(steamid)
data, err := c.request(ctx, endpoint)
if err != nil {
if strings.Contains(err.Error(), "404") {
return 0, ErrNoSlapID
}
return 0, errors.Wrap(err, "c.request")
}
resp, err := unmarshal[idresp](data)
if err != nil {
return 0, errors.Wrap(err, "unmarshal")
}
return resp.ID, nil
}

View File

@@ -0,0 +1,180 @@
package slapshotapi
import (
"fmt"
"strings"
"github.com/pkg/errors"
)
// DetectTampering analyzes 3 period logs for modification signs.
// Returns (isTampering bool, reason string, error).
func DetectTampering(logs []*MatchLog) (bool, string, error) {
if len(logs) != 3 {
return false, "", errors.New("exactly 3 period logs are required")
}
reasons := []string{}
// Check metadata consistency
tampered, reason, err := ValidateMetadataConsistency(logs)
if err != nil {
return false, "", errors.Wrap(err, "ValidateMetadataConsistency")
}
if tampered {
reasons = append(reasons, reason)
}
// Check score progression
tampered, reason, err = ValidateScoreProgression(logs)
if err != nil {
return false, "", errors.Wrap(err, "ValidateScoreProgression")
}
if tampered {
reasons = append(reasons, reason)
}
// Check player consistency
tampered, reason, err = ValidatePlayerConsistency(logs)
if err != nil {
return false, "", errors.Wrap(err, "ValidatePlayerConsistency")
}
if tampered {
reasons = append(reasons, reason)
}
if len(reasons) > 0 {
return true, strings.Join(reasons, "; "), nil
}
return false, "", nil
}
// ValidateMetadataConsistency checks that arena, match_length, and
// custom_mercy_rule are consistent across all periods, and warns if any
// period has periods_enabled set to "False" (periods disabled).
func ValidateMetadataConsistency(logs []*MatchLog) (bool, string, error) {
if len(logs) != 3 {
return false, "", errors.New("exactly 3 period logs are required")
}
ref := logs[0]
inconsistencies := []string{}
for i := 1; i < len(logs); i++ {
log := logs[i]
if log.Arena != ref.Arena {
inconsistencies = append(inconsistencies,
fmt.Sprintf("arena differs in period %d (%q vs %q)", i+1, log.Arena, ref.Arena))
}
if log.MatchLength != ref.MatchLength {
inconsistencies = append(inconsistencies,
fmt.Sprintf("match_length differs in period %d (%s vs %s)", i+1, log.MatchLength, ref.MatchLength))
}
if log.CustomMercyRule != ref.CustomMercyRule {
inconsistencies = append(inconsistencies,
fmt.Sprintf("custom_mercy_rule differs in period %d (%s vs %s)", i+1, log.CustomMercyRule, ref.CustomMercyRule))
}
}
// Warn if any period has periods disabled
for i, log := range logs {
if strings.EqualFold(log.PeriodsEnabled, "False") {
inconsistencies = append(inconsistencies,
fmt.Sprintf("periods_enabled is False in period %d", i+1))
}
}
if len(inconsistencies) > 0 {
return true, "metadata inconsistency: " + strings.Join(inconsistencies, ", "), nil
}
return false, "", nil
}
// ValidateScoreProgression checks that scores only increase or stay the same
// across periods (cumulative stats).
func ValidateScoreProgression(logs []*MatchLog) (bool, string, error) {
if len(logs) != 3 {
return false, "", errors.New("exactly 3 period logs are required")
}
issues := []string{}
for i := 1; i < len(logs); i++ {
prev := logs[i-1]
curr := logs[i]
if curr.Score.Home < prev.Score.Home {
issues = append(issues,
fmt.Sprintf("home score decreased from period %d (%d) to period %d (%d)",
i, prev.Score.Home, i+1, curr.Score.Home))
}
if curr.Score.Away < prev.Score.Away {
issues = append(issues,
fmt.Sprintf("away score decreased from period %d (%d) to period %d (%d)",
i, prev.Score.Away, i+1, curr.Score.Away))
}
}
if len(issues) > 0 {
return true, "score regression: " + strings.Join(issues, ", "), nil
}
return false, "", nil
}
// ValidatePlayerConsistency checks that player rosters remain relatively stable
// across periods. A player present in period 1 should ideally still be present
// in later periods. Drastic roster changes are suspicious.
func ValidatePlayerConsistency(logs []*MatchLog) (bool, string, error) {
if len(logs) != 3 {
return false, "", errors.New("exactly 3 period logs are required")
}
// Build player sets per period
periodPlayers := make([]map[string]string, 3) // game_user_id -> team
for i, log := range logs {
periodPlayers[i] = make(map[string]string)
for _, p := range log.Players {
periodPlayers[i][p.GameUserID] = p.Team
}
}
issues := []string{}
// Check for team-switching between periods (same player, different team)
for i := 1; i < len(logs); i++ {
for id, prevTeam := range periodPlayers[i-1] {
if currTeam, exists := periodPlayers[i][id]; exists {
if currTeam != prevTeam {
issues = append(issues,
fmt.Sprintf("player %s switched teams between period %d and %d",
id, i, i+1))
}
}
}
}
// Check for drastic roster changes (more than 50% different players)
for i := 1; i < len(logs); i++ {
prev := periodPlayers[i-1]
curr := periodPlayers[i]
if len(prev) == 0 {
continue
}
missing := 0
for id := range prev {
if _, exists := curr[id]; !exists {
missing++
}
}
ratio := float64(missing) / float64(len(prev))
if ratio > 0.5 {
issues = append(issues,
fmt.Sprintf("more than 50%% of players changed between period %d and %d (%d/%d missing)",
i, i+1, missing, len(prev)))
}
}
if len(issues) > 0 {
return true, "player inconsistency: " + strings.Join(issues, ", "), nil
}
return false, "", nil
}

158
pkg/slapshotapi/types.go Normal file
View File

@@ -0,0 +1,158 @@
package slapshotapi
// Score represents the score for both teams in a match
type Score struct {
Home int `json:"home"`
Away int `json:"away"`
}
// PlayerStats contains all possible player statistics from a match.
// Fields use pointers because not every stat is present in every match response.
type PlayerStats struct {
Goals *float64 `json:"goals,omitempty"`
Assists *float64 `json:"assists,omitempty"`
PrimaryAssists *float64 `json:"primary_assists,omitempty"`
SecondaryAssists *float64 `json:"secondary_assists,omitempty"`
Saves *float64 `json:"saves,omitempty"`
Blocks *float64 `json:"blocks,omitempty"`
Shots *float64 `json:"shots,omitempty"`
Turnovers *float64 `json:"turnovers,omitempty"`
Takeaways *float64 `json:"takeaways,omitempty"`
Passes *float64 `json:"passes,omitempty"`
PossessionTime *float64 `json:"possession_time_sec,omitempty"`
FaceoffsWon *float64 `json:"faceoffs_won,omitempty"`
FaceoffsLost *float64 `json:"faceoffs_lost,omitempty"`
PostHits *float64 `json:"post_hits,omitempty"`
OvertimeGoals *float64 `json:"overtime_goals,omitempty"`
GameWinningGoals *float64 `json:"game_winning_goals,omitempty"`
Score *float64 `json:"score,omitempty"`
ContributedGoals *float64 `json:"contributed_goals,omitempty"`
ConcededGoals *float64 `json:"conceded_goals,omitempty"`
GamesPlayed *float64 `json:"games_played,omitempty"`
Wins *float64 `json:"wins,omitempty"`
Losses *float64 `json:"losses,omitempty"`
OvertimeWins *float64 `json:"overtime_wins,omitempty"`
OvertimeLosses *float64 `json:"overtime_losses,omitempty"`
Ties *float64 `json:"ties,omitempty"`
Shutouts *float64 `json:"shutouts,omitempty"`
ShutsAgainst *float64 `json:"shutouts_against,omitempty"`
HasMercyRuled *float64 `json:"has_mercy_ruled,omitempty"`
WasMercyRuled *float64 `json:"was_mercy_ruled,omitempty"`
PeriodsPlayed *float64 `json:"periods_played,omitempty"`
}
// Player represents a single player's data in a match
type Player struct {
GameUserID string `json:"game_user_id"`
Team string `json:"team"`
Username string `json:"username"`
Stats PlayerStats `json:"stats"`
}
// GameStats contains the in-game statistics and settings for a match.
// This is the core match data returned both by the API and in local match logs.
type GameStats struct {
Type string `json:"type"`
Arena string `json:"arena"`
Score Score `json:"score"`
Winner string `json:"winner"`
EndReason string `json:"end_reason"`
MatchLength string `json:"match_length"`
PeriodsEnabled string `json:"periods_enabled"`
CurrentPeriod string `json:"current_period"`
CustomMercyRule string `json:"custom_mercy_rule"`
Players []Player `json:"players"`
}
// Game represents a full match as returned by the API /games/{id} endpoint
type Game struct {
ID string `json:"id"`
Region string `json:"region"`
MatchType string `json:"match_type"`
Gamemode string `json:"gamemode"`
Created string `json:"created"`
GameStats GameStats `json:"game_stats"`
}
// LobbyPlayer represents a player in a lobby
type LobbyPlayer struct {
ID int `json:"id"`
Username string `json:"username"`
}
// LobbyTeams represents the team assignments in a lobby
type LobbyTeams struct {
Home []LobbyPlayer `json:"home"`
Away []LobbyPlayer `json:"away"`
Spectators []LobbyPlayer `json:"spectators"`
}
// Lobby represents a custom lobby as returned by the API
type Lobby struct {
UUID string `json:"uuid"`
Region string `json:"region"`
Name string `json:"name"`
HasPassword bool `json:"has_password"`
Owner int `json:"owner"`
OwnerName string `json:"owner_name"`
PlayerCount int `json:"player_count"`
MaxPlayers int `json:"max_players"`
InGame bool `json:"in_game"`
Players LobbyTeams `json:"players"`
MercyRule int `json:"mercy_rule"`
Arena string `json:"arena"`
PeriodsEnabled bool `json:"periods_enabled"`
CurrentPeriod int `json:"current_period"`
Score Score `json:"score"`
Starting bool `json:"starting"`
CanStart bool `json:"can_start"`
}
// LobbyCreationRequest contains the parameters for creating a new lobby
type LobbyCreationRequest struct {
Region string `json:"region"`
Name string `json:"name"`
Password string `json:"password"`
CreatorName string `json:"creator_name"`
// Optional fields
IsPeriods *bool `json:"is_periods,omitempty"`
CurrentPeriod *int `json:"current_period,omitempty"`
MatchLength *int `json:"match_length,omitempty"`
MercyRule *int `json:"mercy_rule,omitempty"`
Arena string `json:"arena,omitempty"`
InitialScore *Score `json:"initial_score,omitempty"`
}
// LobbyCreationResponse is returned when a lobby is successfully created
type LobbyCreationResponse struct {
Success bool `json:"success"`
LobbyID string `json:"lobby_id"`
}
// MatchmakingPlayer represents a player in the matchmaking queue
type MatchmakingPlayer struct {
UUID int `json:"uuid"`
Username string `json:"username"`
}
// MatchmakingRegion represents a region in a matchmaking entity
type MatchmakingRegion struct {
Key string `json:"key"`
Name string `json:"name"`
}
// MatchmakingEntity represents a group/party in the matchmaking queue
type MatchmakingEntity struct {
Players []MatchmakingPlayer `json:"players"`
Regions []MatchmakingRegion `json:"regions"`
MMR int `json:"mmr"`
MMROffset int `json:"mmr_offset"`
}
// MatchmakingResponse is the full matchmaking queue response from the API
type MatchmakingResponse struct {
Entities []MatchmakingEntity `json:"entities"`
Playlists PubsQueue `json:"playlists"`
}