added team view to season_leagues

This commit is contained in:
2026-02-20 19:57:06 +11:00
parent 60120e9d0e
commit e8b492ce4d
25 changed files with 1018 additions and 51 deletions

View File

@@ -33,7 +33,7 @@ type Fixture struct {
func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
homeTeamID, awayTeamID, round int, audit *AuditMeta,
) (*Fixture, error) {
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return nil, errors.Wrap(err, "GetSeasonLeague")
}
@@ -59,7 +59,7 @@ func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName
func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
round int, audit *AuditMeta,
) ([]*Fixture, error) {
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return nil, errors.Wrap(err, "GetSeasonLeague")
}
@@ -71,22 +71,22 @@ func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName s
return fixtures, nil
}
func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Fixture, error) {
season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*SeasonLeague, []*Fixture, error) {
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeasonLeague")
return nil, nil, errors.Wrap(err, "GetSeasonLeague")
}
fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", season.ID).
Where("league_id = ?", league.ID).
Where("season_id = ?", sl.SeasonID).
Where("league_id = ?", sl.LeagueID).
Order("game_week ASC NULLS FIRST", "round ASC", "id ASC").
Relation("HomeTeam").
Relation("AwayTeam").
GetAll(ctx)
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) {
@@ -98,6 +98,22 @@ func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) {
Get(ctx)
}
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) {
fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", seasonID).
@@ -180,13 +196,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 {
season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return errors.Wrap(err, "GetSeasonLeague")
}
err = DeleteItem[Fixture](tx).
Where("season_id = ?", season.ID).
Where("league_id = ?", league.ID).
Where("season_id = ?", sl.SeasonID).
Where("league_id = ?", sl.LeagueID).
WithAudit(audit, nil).
Delete(ctx)
if err != 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

@@ -90,3 +90,16 @@ func UpdatePlayerSlapID(ctx context.Context, tx bun.Tx, playerID int, slapID uin
}
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

@@ -2,6 +2,7 @@ package db
import (
"context"
"database/sql"
"git.haelnorr.com/h/oslstats/internal/permissions"
"github.com/pkg/errors"
@@ -15,8 +16,36 @@ type SeasonLeague struct {
League *League `bun:"rel:belongs-to,join:league_id=id"`
}
// GetSeasonLeague retrieves a specific season-league combination with teams
func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) {
// GetSeasonLeague retrieves a specific season-league combination
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 == "" {
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").
Where("tp.season_id = ? AND tp.league_id = ?", season.ID, league.ID).
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)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "tx.Select teams")

View File

@@ -24,6 +24,7 @@ func (db *DB) RegisterModels() []any {
(*UserRole)(nil),
(*SeasonLeague)(nil),
(*TeamParticipation)(nil),
(*TeamRoster)(nil),
(*User)(nil),
(*DiscordToken)(nil),
(*Season)(nil),

View File

@@ -17,6 +17,7 @@ type Team struct {
Seasons []Season `bun:"m2m:team_participations,join:Team=Season" 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) {

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

@@ -0,0 +1,176 @@
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
}
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.
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")
}
// 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

@@ -16,6 +16,7 @@
--container-lg: 32rem;
--container-2xl: 42rem;
--container-3xl: 48rem;
--container-4xl: 56rem;
--container-5xl: 64rem;
--container-7xl: 80rem;
--text-xs: 0.75rem;
@@ -458,6 +459,9 @@
.max-h-60 {
max-height: calc(var(--spacing) * 60);
}
.max-h-80 {
max-height: calc(var(--spacing) * 80);
}
.max-h-96 {
max-height: calc(var(--spacing) * 96);
}
@@ -467,6 +471,12 @@
.max-h-\[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-height: calc(var(--spacing) * 48);
}
@@ -518,6 +528,9 @@
.max-w-3xl {
max-width: var(--container-3xl);
}
.max-w-4xl {
max-width: var(--container-4xl);
}
.max-w-5xl {
max-width: var(--container-5xl);
}
@@ -908,6 +921,12 @@
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 {
background-color: var(--mantle);
}
@@ -1837,6 +1856,11 @@
display: inline;
}
}
.lg\:grid-cols-2 {
@media (width >= 64rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.lg\:grid-cols-3 {
@media (width >= 64rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));

File diff suppressed because one or more lines are too long

View File

@@ -31,8 +31,7 @@ func GenerateFixtures(
return
}
var season *db.Season
var league *db.League
var sl *db.SeasonLeague
var fixtures []*db.Fixture
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.NewAuditFromRequest(r))
@@ -43,7 +42,7 @@ func GenerateFixtures(
}
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 {
return false, errors.Wrap(err, "db.GetFixtures")
}
@@ -52,7 +51,7 @@ func GenerateFixtures(
return
}
renderSafely(seasonsview.SeasonLeagueManageFixtures(season, league, fixtures), s, r, w)
renderSafely(seasonsview.SeasonLeagueManageFixtures(sl.Season, sl.League, fixtures), s, r, w)
})
}
@@ -82,7 +81,7 @@ func UpdateFixtures(
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
_, _, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
_, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))

View File

@@ -21,11 +21,11 @@ func SeasonLeaguePage(
seasonStr := r.PathValue("season_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) {
var err error
season, _, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
@@ -38,7 +38,7 @@ func SeasonLeaguePage(
return
}
defaultTab := season.GetDefaultTab()
defaultTab := sl.Season.GetDefaultTab()
redirectURL := fmt.Sprintf(
"/seasons/%s/leagues/%s/%s",
seasonStr, leagueStr, defaultTab,

View File

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

View File

@@ -22,13 +22,12 @@ func SeasonLeagueFixturesPage(
seasonShortName := r.PathValue("season_short_name")
leagueShortName := r.PathValue("league_short_name")
var season *db.Season
var league *db.League
var sl *db.SeasonLeague
var fixtures []*db.Fixture
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, 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 db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
@@ -42,9 +41,9 @@ func SeasonLeagueFixturesPage(
}
if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueFixturesPage(season, league, fixtures), s, r, w)
renderSafely(seasonsview.SeasonLeagueFixturesPage(sl.Season, sl.League, fixtures), s, r, w)
} else {
renderSafely(seasonsview.SeasonLeagueFixtures(season, league, fixtures), s, r, w)
renderSafely(seasonsview.SeasonLeagueFixtures(sl.Season, sl.League, fixtures), s, r, w)
}
})
}
@@ -57,13 +56,12 @@ func SeasonLeagueManageFixturesPage(
seasonShortName := r.PathValue("season_short_name")
leagueShortName := r.PathValue("league_short_name")
var season *db.Season
var league *db.League
var sl *db.SeasonLeague
var fixtures []*db.Fixture
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, 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 db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
@@ -76,7 +74,7 @@ func SeasonLeagueManageFixturesPage(
return
}
renderSafely(seasonsview.SeasonLeagueManageFixturesPage(season, league, fixtures), s, r, w)
renderSafely(seasonsview.SeasonLeagueManageFixturesPage(sl.Season, sl.League, fixtures), s, r, w)
})
}
@@ -88,8 +86,7 @@ func SeasonLeagueDeleteFixtures(
seasonShortName := r.PathValue("season_short_name")
leagueShortName := r.PathValue("league_short_name")
var season *db.Season
var league *db.League
var sl *db.SeasonLeague
var fixtures []*db.Fixture
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
err := db.DeleteAllFixtures(ctx, tx, seasonShortName, leagueShortName, db.NewAuditFromRequest(r))
@@ -100,7 +97,7 @@ func SeasonLeagueDeleteFixtures(
}
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 {
return false, errors.Wrap(err, "db.GetFixtures")
}
@@ -109,6 +106,6 @@ func SeasonLeagueDeleteFixtures(
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")
leagueStr := r.PathValue("league_short_name")
var season *db.Season
var league *db.League
var sl *db.SeasonLeague
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, 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 db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
@@ -40,7 +39,7 @@ func SeasonLeagueStatsPage(
}
if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueStatsPage(season, league), s, r, w)
renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League), s, r, w)
} else {
renderSafely(seasonsview.SeasonLeagueStats(), s, r, w)
}

View File

@@ -21,12 +21,11 @@ func SeasonLeagueTablePage(
seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name")
var season *db.Season
var league *db.League
var sl *db.SeasonLeague
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, 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 db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
@@ -39,7 +38,7 @@ func SeasonLeagueTablePage(
return
}
if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueTablePage(season, league), s, r, w)
renderSafely(seasonsview.SeasonLeagueTablePage(sl.Season, sl.League), s, r, w)
} else {
renderSafely(seasonsview.SeasonLeagueTable(), s, r, w)
}

View File

@@ -0,0 +1,63 @@
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
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")
}
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
}
renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available), s, r, w)
})
}

View File

@@ -28,7 +28,7 @@ func SeasonLeagueTeamsPage(
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, 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 db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)

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

@@ -117,6 +117,11 @@ func addRoutes(
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
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",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
@@ -206,6 +211,11 @@ func addRoutes(
Method: hws.MethodPOST,
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{

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

@@ -27,7 +27,18 @@ func (f *FormGetter) Get(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 {
@@ -72,6 +83,10 @@ func (f *FormGetter) Int(key string) *IntField {
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 {
return newTimeField(key, format, f)
}

View File

@@ -46,6 +46,10 @@ func (q *QueryGetter) Int(key string) *IntField {
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 {
return newTimeField(key, format, q)
}

View File

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

View File

@@ -0,0 +1,470 @@
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"
templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player) {
{{
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)
</div>
<!-- Stats below both -->
<div class="mt-6">
@teamStatsSection()
</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) {
<section class="space-y-6">
<!-- Upcoming -->
<div>
<h2 class="text-2xl font-bold text-text mb-4">Upcoming</h2>
if len(fixtures) == 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 fixtures {
@teamFixtureRow(team, fixture)
}
</div>
}
</div>
<!-- Results -->
<div>
<h2 class="text-2xl font-bold text-text mb-4">Results</h2>
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">Results coming soon.</p>
<p class="text-subtext1 text-sm mt-2">Match results will appear here once game data is recorded.</p>
</div>
</div>
</section>
}
templ teamFixtureRow(team *db.Team, fixture *db.Fixture) {
{{
isHome := fixture.HomeTeamID == team.ID
var opponent string
if isHome {
opponent = fixture.AwayTeam.Name
} else {
opponent = fixture.HomeTeam.Name
}
}}
<div class="px-4 py-3 flex items-center justify-between gap-3">
<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>
<span class="text-xs text-subtext1 shrink-0">
TBD
</span>
</div>
}
templ teamStatsSection() {
<section>
<div class="mb-4">
<h2 class="text-2xl font-bold text-text">Stats</h2>
</div>
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">Stats coming soon.</p>
<p class="text-subtext1 text-sm mt-2">Team statistics will appear here once game data is available.</p>
</div>
</section>
}

View File

@@ -122,7 +122,10 @@ templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team,
} else {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, team := range teams {
<div class="bg-surface0 border border-surface1 rounded-lg p-6 hover:bg-surface1 transition-colors hover:cursor-pointer">
<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">
<h3 class="text-xl font-bold text-text">{ team.Name }</h3>
if team.Color != "" {
@@ -140,7 +143,7 @@ templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team,
{ team.AltShortName }
</span>
</div>
</div>
</a>
}
</div>
}