added free agents

This commit is contained in:
2026-02-22 22:44:17 +11:00
parent d011c7acb8
commit f3d1395076
15 changed files with 1549 additions and 98 deletions

View File

@@ -79,6 +79,7 @@ type FixtureResultPlayerStats struct {
HasMercyRuled *int HasMercyRuled *int
WasMercyRuled *int WasMercyRuled *int
PeriodsPlayed *int PeriodsPlayed *int
IsFreeAgent bool `bun:"is_free_agent,default:false"`
FixtureResult *FixtureResult `bun:"rel:belongs-to,join:fixture_result_id=id"` FixtureResult *FixtureResult `bun:"rel:belongs-to,join:fixture_result_id=id"`
Player *Player `bun:"rel:belongs-to,join:player_id=id"` Player *Player `bun:"rel:belongs-to,join:player_id=id"`
@@ -87,10 +88,11 @@ type FixtureResultPlayerStats struct {
// PlayerWithPlayStatus is a helper struct for overview display // PlayerWithPlayStatus is a helper struct for overview display
type PlayerWithPlayStatus struct { type PlayerWithPlayStatus struct {
Player *Player Player *Player
Played bool Played bool
IsManager bool IsManager bool
Stats *FixtureResultPlayerStats // Period 3 (final cumulative) stats, nil if no result 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. // InsertFixtureResult stores a new match result with all player stats in a single transaction.
@@ -489,6 +491,12 @@ func GetFixtureTeamRosters(
// Build maps of player IDs that played and their period 3 stats // Build maps of player IDs that played and their period 3 stats
playedPlayerIDs := map[int]bool{} playedPlayerIDs := map[int]bool{}
playerStatsByID := map[int]*FixtureResultPlayerStats{} 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 { if result != nil {
for _, ps := range result.PlayerStats { for _, ps := range result.PlayerStats {
if ps.PlayerID != nil { if ps.PlayerID != nil {
@@ -496,10 +504,29 @@ func GetFixtureTeamRosters(
if ps.PeriodNum == 3 { if ps.PeriodNum == 3 {
playerStatsByID[*ps.PlayerID] = ps 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 // Build home roster with play status and stats
for _, r := range homeRosters { for _, r := range homeRosters {
played := false played := false
@@ -532,5 +559,29 @@ func GetFixtureTeamRosters(
}) })
} }
// 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 return rosters, 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

@@ -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
},
)
}

View File

@@ -128,6 +128,7 @@ func AddPlayerToTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID,
// ManageTeamRoster replaces the entire roster for a team in a season/league. // ManageTeamRoster replaces the entire roster for a team in a season/league.
// It deletes all existing roster entries and inserts the new ones. // 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 { 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 // Delete all existing roster entries for this team/season/league
_, err := tx.NewDelete(). _, err := tx.NewDelete().
@@ -140,6 +141,56 @@ func ManageTeamRoster(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID
return errors.Wrap(err, "delete existing roster") 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 // Insert manager if provided
if managerID > 0 { if managerID > 0 {
tr := &TeamRoster{ tr := &TeamRoster{

View File

@@ -52,7 +52,6 @@
--ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1);
--animate-spin: spin 1s linear infinite; --animate-spin: spin 1s linear infinite;
--blur-sm: 8px;
--default-transition-duration: 150ms; --default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans); --default-font-family: var(--font-sans);
@@ -1306,6 +1305,12 @@
.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 { .text-yellow\/70 {
color: var(--yellow); color: var(--yellow);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -1367,11 +1372,6 @@
.filter { .filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
} }
.backdrop-blur-sm {
--tw-backdrop-blur: blur(var(--blur-sm));
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
.transition { .transition {
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-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-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -1572,6 +1572,16 @@
} }
} }
} }
.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\:bg-peach\/80 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1592,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) {
@@ -1872,6 +1892,14 @@
} }
} }
} }
.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%;
@@ -2491,42 +2519,6 @@
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
} }
@property --tw-backdrop-blur {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-brightness {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-contrast {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-grayscale {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-hue-rotate {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-invert {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-opacity {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-saturate {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-sepia {
syntax: "*";
inherits: false;
}
@property --tw-duration { @property --tw-duration {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
@@ -2589,15 +2581,6 @@
--tw-drop-shadow-color: initial; --tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%; --tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial; --tw-drop-shadow-size: initial;
--tw-backdrop-blur: initial;
--tw-backdrop-brightness: initial;
--tw-backdrop-contrast: initial;
--tw-backdrop-grayscale: initial;
--tw-backdrop-hue-rotate: initial;
--tw-backdrop-invert: initial;
--tw-backdrop-opacity: initial;
--tw-backdrop-saturate: initial;
--tw-backdrop-sepia: initial;
--tw-duration: initial; --tw-duration: initial;
--tw-ease: initial; --tw-ease: initial;
} }

View File

@@ -7,8 +7,10 @@ import (
"time" "time"
"git.haelnorr.com/h/golib/hws" "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/db"
"git.haelnorr.com/h/oslstats/internal/notify" "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/respond"
"git.haelnorr.com/h/oslstats/internal/throw" "git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/validation" "git.haelnorr.com/h/oslstats/internal/validation"
@@ -43,6 +45,8 @@ func FixtureDetailPage(
var userTeamID int var userTeamID int
var result *db.FixtureResult var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus 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) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
@@ -77,6 +81,19 @@ func FixtureDetailPage(
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters") 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 return true, nil
}); !ok { }); !ok {
return return
@@ -84,7 +101,7 @@ func FixtureDetailPage(
renderSafely(seasonsview.FixtureDetailPage( renderSafely(seasonsview.FixtureDetailPage(
fixture, currentSchedule, history, canSchedule, userTeamID, fixture, currentSchedule, history, canSchedule, userTeamID,
result, rosters, activeTab, result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents,
), s, r, w) ), s, r, w)
}) })
} }
@@ -108,7 +125,8 @@ func ProposeSchedule(
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash(). format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
DayNumeric2().T().Hour24().Colon().Minute().Build() DayNumeric2().T().Hour24().Colon().Minute().Build()
aest, _ := time.LoadLocation("Australia/Sydney") aest, _ := time.LoadLocation("Australia/Sydney")
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value // 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) { if !getter.ValidateAndNotify(s, w, r) {
return return
@@ -147,7 +165,7 @@ func ProposeSchedule(
return return
} }
notify.Success(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil) notify.SuccessWithDelay(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID) respond.HXRedirect(w, "/fixtures/%d", fixtureID)
}) })
} }

View File

@@ -282,6 +282,21 @@ func UploadMatchLogs(
} }
} }
// 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 // Insert result and stats
result, err = db.InsertFixtureResult(ctx, tx, result, playerStats, db.NewAuditFromRequest(r)) result, err = db.InsertFixtureResult(ctx, tx, result, playerStats, db.NewAuditFromRequest(r))
if err != nil { if err != nil {
@@ -294,7 +309,6 @@ func UploadMatchLogs(
} }
_ = unmappedPlayers // stored for review page redirect _ = unmappedPlayers // stored for review page redirect
notify.Success(s, w, r, "Logs Uploaded", "Match logs have been processed. Please review the result.", nil)
respond.HXRedirect(w, "/fixtures/%d/results/review", fixtureID) respond.HXRedirect(w, "/fixtures/%d/results/review", fixtureID)
}) })
} }
@@ -314,6 +328,7 @@ func ReviewMatchResult(
var fixture *db.Fixture var fixture *db.Fixture
var result *db.FixtureResult var result *db.FixtureResult
var unmappedPlayers []string var unmappedPlayers []string
var faWarnings []seasonsview.FreeAgentWarning
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
@@ -335,11 +350,52 @@ func ReviewMatchResult(
return false, nil return false, nil
} }
// Build unmapped players list from stats // 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 { for _, ps := range result.PlayerStats {
if ps.PlayerID == nil && ps.PeriodNum == 3 { if ps.PeriodNum != 3 {
continue
}
if ps.PlayerID == nil {
unmappedPlayers = append(unmappedPlayers, unmappedPlayers = append(unmappedPlayers,
ps.PlayerGameUserID+" ("+ps.PlayerUsername+")") 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),
})
}
} }
} }
@@ -348,7 +404,7 @@ func ReviewMatchResult(
return return
} }
renderSafely(seasonsview.FixtureReviewResultPage(fixture, result, unmappedPlayers), s, r, w) renderSafely(seasonsview.FixtureReviewResultPage(fixture, result, unmappedPlayers, faWarnings), s, r, w)
}) })
} }

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

@@ -39,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

@@ -147,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{
@@ -234,6 +250,17 @@ func addRoutes(
Method: hws.MethodPOST, Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.CancelSchedule(s, conn)), 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) // Match result management routes (all require fixtures.manage permission)
{ {
Path: "/fixtures/{fixture_id}/results/upload", Path: "/fixtures/{fixture_id}/results/upload",

View File

@@ -17,6 +17,8 @@ templ FixtureDetailPage(
result *db.FixtureResult, result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus, rosters map[string][]*db.PlayerWithPlayStatus,
activeTab string, activeTab string,
nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent,
) { ) {
{{ {{
permCache := contexts.Permissions(ctx) permCache := contexts.Permissions(ctx)
@@ -78,10 +80,10 @@ templ FixtureDetailPage(
</nav> </nav>
} }
</div> </div>
<!-- Tab Content --> <!-- Tab Content -->
if activeTab == "overview" { if activeTab == "overview" {
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage) @fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents)
} else if activeTab == "schedule" { } else if activeTab == "schedule" {
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID) @fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
} }
</div> </div>
@@ -116,6 +118,10 @@ templ fixtureOverviewTab(
result *db.FixtureResult, result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus, rosters map[string][]*db.PlayerWithPlayStatus,
canManage bool, canManage bool,
canSchedule bool,
userTeamID int,
nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent,
) { ) {
<div class="space-y-6"> <div class="space-y-6">
<!-- Result + Schedule Row --> <!-- Result + Schedule Row -->
@@ -135,6 +141,10 @@ templ fixtureOverviewTab(
@fixtureScheduleSummary(fixture, currentSchedule, result) @fixtureScheduleSummary(fixture, currentSchedule, result)
</div> </div>
</div> </div>
<!-- Free Agent Nominations (hidden when result is finalized) -->
if (result == nil || !result.Finalized) && (canSchedule || canManage || len(nominatedFreeAgents) > 0) {
@fixtureFreeAgentSection(fixture, canSchedule, canManage, userTeamID, nominatedFreeAgents, availableFreeAgents)
}
<!-- Team Rosters --> <!-- Team Rosters -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result) @fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result)
@@ -400,6 +410,11 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
&#9733; &#9733;
</span> </span>
} }
if p.IsFreeAgent {
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FREE AGENT
</span>
}
</span> </span>
</td> </td>
if p.Stats != nil { if p.Stats != nil {
@@ -456,6 +471,11 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
&#9733; Manager &#9733; Manager
</span> </span>
} }
if p.IsFreeAgent {
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FREE AGENT
</span>
}
</div> </div>
} }
</div> </div>
@@ -482,6 +502,256 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
</div> </div>
} }
// ==================== Free Agent Section ====================
templ fixtureFreeAgentSection(
fixture *db.Fixture,
canSchedule bool,
canManage bool,
userTeamID int,
nominated []*db.FixtureFreeAgent,
available []*db.SeasonLeagueFreeAgent,
) {
{{
// Split nominated by team
homeNominated := []*db.FixtureFreeAgent{}
awayNominated := []*db.FixtureFreeAgent{}
for _, n := range nominated {
if n.TeamID == fixture.HomeTeamID {
homeNominated = append(homeNominated, n)
} else {
awayNominated = append(awayNominated, n)
}
}
// Filter available: exclude already nominated players
nominatedIDs := map[int]bool{}
for _, n := range nominated {
nominatedIDs[n.PlayerID] = true
}
filteredAvailable := []*db.SeasonLeagueFreeAgent{}
for _, fa := range available {
if !nominatedIDs[fa.PlayerID] {
filteredAvailable = append(filteredAvailable, fa)
}
}
// Can the user nominate?
canNominate := (canSchedule || canManage) && len(filteredAvailable) > 0
}}
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden" x-data="{ showNominateModal: false, selectedPlayerId: '', selectedTeamId: '' }">
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
<h2 class="text-lg font-bold text-text">Free Agent Nominations</h2>
if canNominate {
<button
@click="showNominateModal = true"
class="rounded-lg px-3 py-1.5 hover:cursor-pointer text-center text-xs
bg-peach hover:bg-peach/75 text-mantle transition"
>
Nominate Free Agent
</button>
}
</div>
<div class="p-4">
if len(nominated) == 0 {
<p class="text-subtext1 text-sm text-center py-2">No free agents nominated for this fixture.</p>
} else {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Home team nominations -->
<div>
<p class="text-xs text-subtext0 font-semibold uppercase mb-2">{ fixture.HomeTeam.Name }</p>
if len(homeNominated) == 0 {
<p class="text-subtext1 text-xs italic">None</p>
} else {
<div class="space-y-1">
for _, n := range homeNominated {
<div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition">
<span class="flex items-center gap-2">
<span class="text-sm text-text">{ n.Player.DisplayName() }</span>
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FA
</span>
</span>
if canManage || (canSchedule && userTeamID == fixture.HomeTeamID) {
<form
hx-post={ fmt.Sprintf("/fixtures/%d/free-agents/%d/remove", fixture.ID, n.PlayerID) }
hx-swap="none"
class="inline"
>
<button
type="submit"
class="px-2 py-0.5 text-xs bg-red/20 hover:bg-red/40 text-red rounded
transition hover:cursor-pointer"
>
Remove
</button>
</form>
}
</div>
}
</div>
}
</div>
<!-- Away team nominations -->
<div>
<p class="text-xs text-subtext0 font-semibold uppercase mb-2">{ fixture.AwayTeam.Name }</p>
if len(awayNominated) == 0 {
<p class="text-subtext1 text-xs italic">None</p>
} else {
<div class="space-y-1">
for _, n := range awayNominated {
<div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition">
<span class="flex items-center gap-2">
<span class="text-sm text-text">{ n.Player.DisplayName() }</span>
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FA
</span>
</span>
if canManage || (canSchedule && userTeamID == fixture.AwayTeamID) {
<form
hx-post={ fmt.Sprintf("/fixtures/%d/free-agents/%d/remove", fixture.ID, n.PlayerID) }
hx-swap="none"
class="inline"
>
<button
type="submit"
class="px-2 py-0.5 text-xs bg-red/20 hover:bg-red/40 text-red rounded
transition hover:cursor-pointer"
>
Remove
</button>
</form>
}
</div>
}
</div>
}
</div>
</div>
}
</div>
<!-- Nominate Modal -->
if canNominate {
<div
x-show="showNominateModal"
@keydown.escape.window="showNominateModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
style="display: none;"
>
<div
class="fixed inset-0 bg-crust/80 transition-opacity"
@click="showNominateModal = false"
></div>
<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">Nominate Free Agent</h3>
<form
hx-post={ fmt.Sprintf("/fixtures/%d/free-agents/nominate", fixture.ID) }
hx-swap="none"
>
if canManage && !canSchedule {
<!-- Manager (not on either team): show team selector -->
<div class="mb-4">
<label for="fa_team_id" class="block text-sm font-medium mb-2">Nominating Team</label>
<select
id="fa_team_id"
name="team_id"
x-model="selectedTeamId"
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 team...</option>
<option value={ fmt.Sprint(fixture.HomeTeamID) }>
{ fixture.HomeTeam.Name }
</option>
<option value={ fmt.Sprint(fixture.AwayTeamID) }>
{ fixture.AwayTeam.Name }
</option>
</select>
</div>
} else if canManage && canSchedule {
<!-- Manager who is also on a team: show team selector pre-filled -->
<div class="mb-4">
<label for="fa_team_id" class="block text-sm font-medium mb-2">Nominating Team</label>
<select
id="fa_team_id"
name="team_id"
x-model="selectedTeamId"
x-init={ fmt.Sprintf("selectedTeamId = '%d'", userTeamID) }
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 team...</option>
<option value={ fmt.Sprint(fixture.HomeTeamID) }>
{ fixture.HomeTeam.Name }
</option>
<option value={ fmt.Sprint(fixture.AwayTeamID) }>
{ fixture.AwayTeam.Name }
</option>
</select>
</div>
} else {
<!-- Regular team manager: fixed to their team -->
<input type="hidden" name="team_id" value={ fmt.Sprint(userTeamID) }/>
}
<div class="mb-4">
<label for="fa_player_id" class="block text-sm font-medium mb-2">Select Free Agent</label>
<select
id="fa_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 free agent...</option>
for _, fa := range filteredAvailable {
<option value={ fmt.Sprint(fa.PlayerID) }>
{ fa.Player.DisplayName() }
</option>
}
</select>
</div>
<div class="flex gap-3 justify-end">
<button
type="button"
@click="showNominateModal = false"
class="px-4 py-2 rounded-lg bg-surface0 hover:bg-surface1 text-text transition hover:cursor-pointer"
>
Cancel
</button>
if canManage {
<button
type="submit"
:disabled="!selectedPlayerId || !selectedTeamId"
class="px-4 py-2 rounded-lg bg-peach hover:bg-peach/75 text-mantle transition
disabled:bg-peach/40 disabled:cursor-not-allowed hover:cursor-pointer"
>
Nominate
</button>
} else {
<button
type="submit"
:disabled="!selectedPlayerId"
class="px-4 py-2 rounded-lg bg-peach hover:bg-peach/75 text-mantle transition
disabled:bg-peach/40 disabled:cursor-not-allowed hover:cursor-pointer"
>
Nominate
</button>
}
</div>
</form>
</div>
</div>
</div>
}
</div>
}
// ==================== Schedule Tab ==================== // ==================== Schedule Tab ====================
templ fixtureScheduleTab( templ fixtureScheduleTab(
fixture *db.Fixture, fixture *db.Fixture,

View File

@@ -8,6 +8,7 @@ templ FixtureReviewResultPage(
fixture *db.Fixture, fixture *db.Fixture,
result *db.FixtureResult, result *db.FixtureResult,
unmappedPlayers []string, unmappedPlayers []string,
unnominatedFreeAgents []FreeAgentWarning,
) { ) {
{{ {{
backURL := fmt.Sprintf("/fixtures/%d", fixture.ID) backURL := fmt.Sprintf("/fixtures/%d", fixture.ID)
@@ -37,38 +38,56 @@ templ FixtureReviewResultPage(
</div> </div>
</div> </div>
</div> </div>
<!-- Warnings Section --> <!-- Warnings Section -->
if result.TamperingDetected || len(unmappedPlayers) > 0 { if result.TamperingDetected || len(unmappedPlayers) > 0 || len(unnominatedFreeAgents) > 0 {
<div class="space-y-4 mb-6"> <div class="space-y-4 mb-6">
if result.TamperingDetected && result.TamperingReason != nil { if result.TamperingDetected && result.TamperingReason != nil {
<div class="bg-red/10 border border-red/30 rounded-lg p-4"> <div class="bg-red/10 border border-red/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<span class="text-red font-bold text-sm">⚠ Inconsistent Data Detected</span> <span class="text-red font-bold text-sm">⚠ Inconsistent Data Detected</span>
</div> </div>
<p class="text-red/80 text-sm">{ *result.TamperingReason }</p> <p class="text-red/80 text-sm">{ *result.TamperingReason }</p>
<p class="text-red/60 text-xs mt-2"> <p class="text-red/60 text-xs mt-2">
This does not block finalization but should be reviewed carefully. This does not block finalization but should be reviewed carefully.
</p> </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> </div>
} <p class="text-yellow/80 text-sm mb-2">
if len(unmappedPlayers) > 0 { The following free agents have nomination issues that should be reviewed before finalizing.
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4"> </p>
<div class="flex items-center gap-2 mb-2"> <ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
<span class="text-yellow font-bold text-sm">⚠ Unmapped Players</span> for _, fa := range unnominatedFreeAgents {
</div> <li>
<p class="text-yellow/80 text-sm mb-2"> <span class="text-yellow font-medium">{ fa.Name }</span>
The following players could not be matched to registered players. <span class="text-yellow/60"> — { fa.Reason }</span>
They may be free agents or have unregistered Slapshot IDs. </li>
</p> }
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5"> </ul>
for _, p := range unmappedPlayers { </div>
<li>{ p }</li> }
} if len(unmappedPlayers) > 0 {
</ul> <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> </div>
} <p class="text-yellow/80 text-sm mb-2">
</div> 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 --> <!-- Score Overview -->
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6"> <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"> <div class="bg-surface0 border-b border-surface1 px-4 py-3">
@@ -199,10 +218,17 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
for _, ps := range finalStats { for _, ps := range finalStats {
<tr class="hover:bg-surface0 transition-colors"> <tr class="hover:bg-surface0 transition-colors">
<td class="px-3 py-2 text-sm text-text"> <td class="px-3 py-2 text-sm text-text">
{ ps.Username } <span class="flex items-center gap-1.5">
if ps.PlayerID == nil { { ps.Username }
<span class="text-yellow text-xs ml-1" title="Unmapped player">?</span> 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>
<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.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.Assists) }</td>

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

@@ -120,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

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