added team overview
This commit is contained in:
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
@@ -72,3 +73,149 @@ func (t *Team) InSeason(seasonID int) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TeamSeasonInfo holds information about a team's participation in a specific season+league.
|
||||
type TeamSeasonInfo struct {
|
||||
Season *Season
|
||||
League *League
|
||||
Record *TeamRecord
|
||||
TotalTeams int
|
||||
Position int
|
||||
}
|
||||
|
||||
// GetTeamSeasonParticipation returns all season+league combos the team participated in,
|
||||
// with computed records, positions, and total team counts.
|
||||
func GetTeamSeasonParticipation(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
teamID int,
|
||||
) ([]*TeamSeasonInfo, error) {
|
||||
if teamID == 0 {
|
||||
return nil, errors.New("teamID not provided")
|
||||
}
|
||||
|
||||
// Get all participations for this team
|
||||
var participations []*TeamParticipation
|
||||
err := tx.NewSelect().
|
||||
Model(&participations).
|
||||
Where("team_id = ?", teamID).
|
||||
Relation("Season", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Relation("Leagues")
|
||||
}).
|
||||
Relation("League").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect participations")
|
||||
}
|
||||
|
||||
var results []*TeamSeasonInfo
|
||||
|
||||
for _, p := range participations {
|
||||
// Get all teams in this season+league for position calculation
|
||||
var teams []*Team
|
||||
err := tx.NewSelect().
|
||||
Model(&teams).
|
||||
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
|
||||
Where("tp.season_id = ? AND tp.league_id = ?", p.SeasonID, p.LeagueID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect teams")
|
||||
}
|
||||
|
||||
// Get all fixtures for this season+league
|
||||
fixtures, err := GetAllocatedFixtures(ctx, tx, p.SeasonID, p.LeagueID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetAllocatedFixtures")
|
||||
}
|
||||
|
||||
fixtureIDs := make([]int, len(fixtures))
|
||||
for i, f := range fixtures {
|
||||
fixtureIDs[i] = f.ID
|
||||
}
|
||||
|
||||
resultMap, err := GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures")
|
||||
}
|
||||
|
||||
// Compute leaderboard to get position
|
||||
leaderboard := ComputeLeaderboard(teams, fixtures, resultMap)
|
||||
|
||||
var position int
|
||||
var record *TeamRecord
|
||||
for _, entry := range leaderboard {
|
||||
if entry.Team.ID == teamID {
|
||||
position = entry.Position
|
||||
record = entry.Record
|
||||
break
|
||||
}
|
||||
}
|
||||
if record == nil {
|
||||
record = &TeamRecord{}
|
||||
}
|
||||
|
||||
results = append(results, &TeamSeasonInfo{
|
||||
Season: p.Season,
|
||||
League: p.League,
|
||||
Record: record,
|
||||
TotalTeams: len(teams),
|
||||
Position: position,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by season start date descending (newest first)
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].Season.StartDate.After(results[j].Season.StartDate)
|
||||
})
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// TeamAllTimePlayerStats holds aggregated all-time stats for a player on a team.
|
||||
type TeamAllTimePlayerStats struct {
|
||||
PlayerID int `bun:"player_id"`
|
||||
PlayerName string `bun:"player_name"`
|
||||
SeasonsPlayed int `bun:"seasons_played"`
|
||||
PeriodsPlayed int `bun:"total_periods_played"`
|
||||
Goals int `bun:"total_goals"`
|
||||
Assists int `bun:"total_assists"`
|
||||
Saves int `bun:"total_saves"`
|
||||
}
|
||||
|
||||
// GetTeamAllTimePlayerStats returns aggregated all-time stats for all players
|
||||
// who have ever played for a given team across all seasons.
|
||||
func GetTeamAllTimePlayerStats(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
teamID int,
|
||||
) ([]*TeamAllTimePlayerStats, error) {
|
||||
if teamID == 0 {
|
||||
return nil, errors.New("teamID not provided")
|
||||
}
|
||||
|
||||
var stats []*TeamAllTimePlayerStats
|
||||
err := tx.NewRaw(`
|
||||
SELECT
|
||||
frps.player_id AS player_id,
|
||||
COALESCE(p.name, frps.player_username) AS player_name,
|
||||
COUNT(DISTINCT s.id) AS seasons_played,
|
||||
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
||||
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
||||
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
||||
COALESCE(SUM(frps.saves), 0) AS total_saves
|
||||
FROM fixture_result_player_stats frps
|
||||
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
||||
JOIN fixtures f ON f.id = fr.fixture_id
|
||||
JOIN seasons s ON s.id = f.season_id
|
||||
LEFT JOIN players p ON p.id = frps.player_id
|
||||
WHERE fr.finalized = true
|
||||
AND frps.team_id = ?
|
||||
AND frps.period_num = 3
|
||||
AND frps.player_id IS NOT NULL
|
||||
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
||||
`, teamID).Scan(ctx, &stats)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewRaw")
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
67
internal/handlers/team_detail.go
Normal file
67
internal/handlers/team_detail.go
Normal file
@@ -0,0 +1,67 @@
|
||||
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"
|
||||
teamsview "git.haelnorr.com/h/oslstats/internal/view/teamsview"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// TeamDetailPage renders the global team detail page showing cross-season stats
|
||||
func TeamDetailPage(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
teamIDStr := r.PathValue("team_id")
|
||||
|
||||
teamID, err := strconv.Atoi(teamIDStr)
|
||||
if err != nil {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
var team *db.Team
|
||||
var seasonInfos []*db.TeamSeasonInfo
|
||||
var playerStats []*db.TeamAllTimePlayerStats
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
team, err = db.GetTeam(ctx, tx, 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.GetTeam")
|
||||
}
|
||||
|
||||
seasonInfos, err = db.GetTeamSeasonParticipation(ctx, tx, teamID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetTeamSeasonParticipation")
|
||||
}
|
||||
|
||||
playerStats, err = db.GetTeamAllTimePlayerStats(ctx, tx, teamID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetTeamAllTimePlayerStats")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
activeTab := r.URL.Query().Get("tab")
|
||||
if activeTab != "seasons" && activeTab != "stats" {
|
||||
activeTab = "seasons"
|
||||
}
|
||||
|
||||
renderSafely(teamsview.DetailPage(team, seasonInfos, playerStats, activeTab), s, r, w)
|
||||
})
|
||||
}
|
||||
@@ -316,6 +316,11 @@ func addRoutes(
|
||||
Method: hws.MethodPOST,
|
||||
Handler: perms.RequirePermission(s, permissions.TeamsManagePlayers)(handlers.ManageTeamRoster(s, conn)),
|
||||
},
|
||||
{
|
||||
Path: "/teams/{team_id}",
|
||||
Method: hws.MethodGET,
|
||||
Handler: handlers.TeamDetailPage(s, conn),
|
||||
},
|
||||
}
|
||||
|
||||
htmxRoutes := []hws.Route{
|
||||
|
||||
@@ -42,13 +42,22 @@ templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture,
|
||||
</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 class="flex items-center gap-2">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/teams/%d", team.ID)) }
|
||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||
bg-surface0 border border-surface1 hover:bg-surface1 text-subtext0 hover:text-text transition text-sm"
|
||||
>
|
||||
View All Seasons
|
||||
</a>
|
||||
<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>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
|
||||
81
internal/view/teamsview/detail_page.templ
Normal file
81
internal/view/teamsview/detail_page.templ
Normal file
@@ -0,0 +1,81 @@
|
||||
package teamsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||
import "fmt"
|
||||
|
||||
templ DetailPage(team *db.Team, seasonInfos []*db.TeamSeasonInfo, playerStats []*db.TeamAllTimePlayerStats, activeTab string) {
|
||||
@baseview.Layout(team.Name) {
|
||||
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<!-- Header -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/teams"
|
||||
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>
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="bg-surface0 border-b border-surface1">
|
||||
<ul class="flex flex-wrap">
|
||||
@teamDetailTab("seasons", "Seasons", activeTab, team)
|
||||
@teamDetailTab("stats", "Player Stats", activeTab, team)
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<!-- Tab Content -->
|
||||
<div class="mt-6">
|
||||
if activeTab == "seasons" {
|
||||
@TeamDetailSeasons(team, seasonInfos)
|
||||
} else if activeTab == "stats" {
|
||||
@TeamDetailPlayerStats(playerStats)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ teamDetailTab(section string, label string, activeTab string, team *db.Team) {
|
||||
{{
|
||||
isActive := section == activeTab
|
||||
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
|
||||
activeClasses := "border-blue text-blue font-semibold"
|
||||
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
||||
url := fmt.Sprintf("/teams/%d", team.ID)
|
||||
if section != "seasons" {
|
||||
url = fmt.Sprintf("/teams/%d?tab=%s", team.ID, section)
|
||||
}
|
||||
}}
|
||||
<li class="inline-block">
|
||||
<a
|
||||
href={ templ.SafeURL(url) }
|
||||
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||||
>
|
||||
{ label }
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
127
internal/view/teamsview/detail_player_stats.templ
Normal file
127
internal/view/teamsview/detail_player_stats.templ
Normal file
@@ -0,0 +1,127 @@
|
||||
package teamsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "fmt"
|
||||
import "sort"
|
||||
|
||||
templ TeamDetailPlayerStats(playerStats []*db.TeamAllTimePlayerStats) {
|
||||
if len(playerStats) == 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No player stats yet.</p>
|
||||
<p class="text-subtext1 text-sm mt-2">Player statistics will appear here once games are played.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div x-data="{ activeView: 'goals' }">
|
||||
<!-- Sub-view Tabs -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button
|
||||
@click="activeView = 'goals'"
|
||||
:class="activeView === 'goals'
|
||||
? 'bg-blue text-mantle'
|
||||
: 'bg-surface0 border border-surface1 text-subtext0 hover:text-text hover:bg-surface1'"
|
||||
class="px-4 py-2 rounded-lg font-medium text-sm transition hover:cursor-pointer"
|
||||
>
|
||||
Goals
|
||||
</button>
|
||||
<button
|
||||
@click="activeView = 'assists'"
|
||||
:class="activeView === 'assists'
|
||||
? 'bg-blue text-mantle'
|
||||
: 'bg-surface0 border border-surface1 text-subtext0 hover:text-text hover:bg-surface1'"
|
||||
class="px-4 py-2 rounded-lg font-medium text-sm transition hover:cursor-pointer"
|
||||
>
|
||||
Assists
|
||||
</button>
|
||||
<button
|
||||
@click="activeView = 'saves'"
|
||||
:class="activeView === 'saves'
|
||||
? 'bg-blue text-mantle'
|
||||
: 'bg-surface0 border border-surface1 text-subtext0 hover:text-text hover:bg-surface1'"
|
||||
class="px-4 py-2 rounded-lg font-medium text-sm transition hover:cursor-pointer"
|
||||
>
|
||||
Saves
|
||||
</button>
|
||||
</div>
|
||||
<!-- Goals View -->
|
||||
<div x-show="activeView === 'goals'">
|
||||
@playerStatsTable(playerStats, "goals")
|
||||
</div>
|
||||
<!-- Assists View -->
|
||||
<div x-show="activeView === 'assists'" style="display: none;">
|
||||
@playerStatsTable(playerStats, "assists")
|
||||
</div>
|
||||
<!-- Saves View -->
|
||||
<div x-show="activeView === 'saves'" style="display: none;">
|
||||
@playerStatsTable(playerStats, "saves")
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ playerStatsTable(playerStats []*db.TeamAllTimePlayerStats, statType string) {
|
||||
{{
|
||||
// Make a copy so sorting doesn't affect other views
|
||||
sorted := make([]*db.TeamAllTimePlayerStats, len(playerStats))
|
||||
copy(sorted, playerStats)
|
||||
|
||||
switch statType {
|
||||
case "goals":
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Goals > sorted[j].Goals
|
||||
})
|
||||
case "assists":
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Assists > sorted[j].Assists
|
||||
})
|
||||
case "saves":
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Saves > sorted[j].Saves
|
||||
})
|
||||
}
|
||||
|
||||
statLabel := "Goals"
|
||||
statShort := "G"
|
||||
if statType == "assists" {
|
||||
statLabel = "Assists"
|
||||
statShort = "A"
|
||||
} else if statType == "saves" {
|
||||
statLabel = "Saves"
|
||||
statShort = "SV"
|
||||
}
|
||||
_ = statLabel
|
||||
}}
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Seasons Played">SZN</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-blue" title={ statLabel }>{ statShort }</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for i, ps := range sorted {
|
||||
<tr class="hover:bg-surface1 transition-colors">
|
||||
<td class="px-3 py-2 text-center text-sm font-medium text-subtext0">
|
||||
{ fmt.Sprint(i + 1) }
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-text font-medium">{ ps.PlayerName }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.SeasonsPlayed) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.PeriodsPlayed) }</td>
|
||||
if statType == "goals" {
|
||||
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(ps.Goals) }</td>
|
||||
} else if statType == "assists" {
|
||||
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(ps.Assists) }</td>
|
||||
} else {
|
||||
<td class="px-2 py-2 text-center text-sm font-bold text-blue">{ fmt.Sprint(ps.Saves) }</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
86
internal/view/teamsview/detail_seasons.templ
Normal file
86
internal/view/teamsview/detail_seasons.templ
Normal file
@@ -0,0 +1,86 @@
|
||||
package teamsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||
import "fmt"
|
||||
|
||||
templ TeamDetailSeasons(team *db.Team, seasonInfos []*db.TeamSeasonInfo) {
|
||||
if len(seasonInfos) == 0 {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">No season history yet.</p>
|
||||
<p class="text-subtext1 text-sm mt-2">This team has not participated in any seasons.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
for _, info := range seasonInfos {
|
||||
@teamSeasonCard(team, info)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ teamSeasonCard(team *db.Team, info *db.TeamSeasonInfo) {
|
||||
{{
|
||||
detailURL := fmt.Sprintf(
|
||||
"/seasons/%s/leagues/%s/teams/%d",
|
||||
info.Season.ShortName, info.League.ShortName, team.ID,
|
||||
)
|
||||
}}
|
||||
<a
|
||||
href={ templ.SafeURL(detailURL) }
|
||||
class="bg-mantle border border-surface1 rounded-lg overflow-hidden
|
||||
hover:bg-surface0 transition hover:cursor-pointer block"
|
||||
>
|
||||
<!-- Card Header -->
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-bold text-text">{ info.Season.Name }</h3>
|
||||
<span class="text-subtext0 text-sm">—</span>
|
||||
<span class="text-subtext0 text-sm">{ info.League.Name }</span>
|
||||
</div>
|
||||
@seasonsview.StatusBadge(info.Season, true, true)
|
||||
</div>
|
||||
<!-- Card Body -->
|
||||
<div class="p-4">
|
||||
<!-- Position & Points Row -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Position Badge -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-subtext0 uppercase font-medium">Position</span>
|
||||
<span class="text-2xl font-bold text-text">
|
||||
{ fmt.Sprint(info.Position) }
|
||||
</span>
|
||||
<span class="text-sm text-subtext0">
|
||||
/ { fmt.Sprint(info.TotalTeams) }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Points -->
|
||||
<div class="text-right">
|
||||
<span class="text-xs text-subtext0 uppercase font-medium">Points</span>
|
||||
<p class="text-2xl font-bold text-blue">{ fmt.Sprint(info.Record.Points) }</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Record Row -->
|
||||
<div class="grid grid-cols-4 gap-2 text-center">
|
||||
<div class="bg-surface0 rounded px-2 py-1.5">
|
||||
<p class="text-xs text-subtext0 font-medium">W</p>
|
||||
<p class="text-sm font-bold text-green">{ fmt.Sprint(info.Record.Wins) }</p>
|
||||
</div>
|
||||
<div class="bg-surface0 rounded px-2 py-1.5">
|
||||
<p class="text-xs text-subtext0 font-medium">OTW</p>
|
||||
<p class="text-sm font-bold text-teal">{ fmt.Sprint(info.Record.OvertimeWins) }</p>
|
||||
</div>
|
||||
<div class="bg-surface0 rounded px-2 py-1.5">
|
||||
<p class="text-xs text-subtext0 font-medium">OTL</p>
|
||||
<p class="text-sm font-bold text-peach">{ fmt.Sprint(info.Record.OvertimeLosses) }</p>
|
||||
</div>
|
||||
<div class="bg-surface0 rounded px-2 py-1.5">
|
||||
<p class="text-xs text-subtext0 font-medium">L</p>
|
||||
<p class="text-sm font-bold text-red">{ fmt.Sprint(info.Record.Losses) }</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import "git.haelnorr.com/h/oslstats/internal/view/sort"
|
||||
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||
import "github.com/uptrace/bun"
|
||||
import "fmt"
|
||||
|
||||
templ ListPage(teams *db.List[db.Team]) {
|
||||
@baseview.Layout("Teams") {
|
||||
@@ -80,8 +81,10 @@ templ TeamsList(teams *db.List[db.Team]) {
|
||||
<!-- Card grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
for _, t := range teams.Items {
|
||||
<div
|
||||
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0 transition-colors flex flex-col"
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/teams/%d", t.ID)) }
|
||||
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0
|
||||
transition-colors flex flex-col hover:cursor-pointer"
|
||||
>
|
||||
<!-- Header: Name with color indicator -->
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
@@ -102,7 +105,7 @@ templ TeamsList(teams *db.List[db.Team]) {
|
||||
{ t.AltShortName }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<!-- Pagination controls -->
|
||||
|
||||
Reference in New Issue
Block a user