added match preview and analysis
This commit is contained in:
254
internal/db/match_preview.go
Normal file
254
internal/db/match_preview.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// Game outcome type constants.
|
||||
const (
|
||||
OutcomeWin = "W"
|
||||
OutcomeLoss = "L"
|
||||
OutcomeOTWin = "OTW"
|
||||
OutcomeOTLoss = "OTL"
|
||||
OutcomeDraw = "D"
|
||||
OutcomeForfeit = "F"
|
||||
)
|
||||
|
||||
// GameOutcome represents the result of a single game from a team's perspective.
|
||||
type GameOutcome struct {
|
||||
Type string // One of Outcome* constants: "W", "L", "OTW", "OTL", "D", "F"
|
||||
Opponent *Team // The opposing team (may be nil if relation not loaded)
|
||||
Score string // e.g. "3-1" or "" for forfeits
|
||||
IsForfeit bool // Whether this game was decided by forfeit
|
||||
Fixture *Fixture // The fixture itself
|
||||
}
|
||||
|
||||
// MatchPreviewData holds all computed data needed for the match preview tab.
|
||||
type MatchPreviewData struct {
|
||||
HomeRecord *TeamRecord
|
||||
AwayRecord *TeamRecord
|
||||
HomePosition int
|
||||
AwayPosition int
|
||||
TotalTeams int
|
||||
HomeRecentGames []*GameOutcome
|
||||
AwayRecentGames []*GameOutcome
|
||||
}
|
||||
|
||||
// ComputeRecentGames calculates the last N game outcomes for a given team.
|
||||
// Fixtures should be all allocated fixtures for the season+league.
|
||||
// Results should be finalized results mapped by fixture ID.
|
||||
// Schedules should be accepted schedules mapped by fixture ID (for ordering by scheduled time).
|
||||
// The returned outcomes are in chronological order (oldest first, newest last).
|
||||
func ComputeRecentGames(
|
||||
teamID int,
|
||||
fixtures []*Fixture,
|
||||
resultMap map[int]*FixtureResult,
|
||||
scheduleMap map[int]*FixtureSchedule,
|
||||
limit int,
|
||||
) []*GameOutcome {
|
||||
// Collect fixtures involving this team that have finalized results
|
||||
type fixtureWithTime struct {
|
||||
fixture *Fixture
|
||||
result *FixtureResult
|
||||
time time.Time
|
||||
}
|
||||
var played []fixtureWithTime
|
||||
|
||||
for _, f := range fixtures {
|
||||
if f.HomeTeamID != teamID && f.AwayTeamID != teamID {
|
||||
continue
|
||||
}
|
||||
res, ok := resultMap[f.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Use schedule time for ordering, fall back to result creation time
|
||||
t := time.Unix(res.CreatedAt, 0)
|
||||
if scheduleMap != nil {
|
||||
if sched, ok := scheduleMap[f.ID]; ok && sched.ScheduledTime != nil {
|
||||
t = *sched.ScheduledTime
|
||||
}
|
||||
}
|
||||
played = append(played, fixtureWithTime{fixture: f, result: res, time: t})
|
||||
}
|
||||
|
||||
// Sort by time descending (most recent first)
|
||||
sort.Slice(played, func(i, j int) bool {
|
||||
return played[i].time.After(played[j].time)
|
||||
})
|
||||
|
||||
// Take only the most recent N
|
||||
if len(played) > limit {
|
||||
played = played[:limit]
|
||||
}
|
||||
|
||||
// Reverse to chronological order (oldest first)
|
||||
for i, j := 0, len(played)-1; i < j; i, j = i+1, j-1 {
|
||||
played[i], played[j] = played[j], played[i]
|
||||
}
|
||||
|
||||
// Build outcome list
|
||||
outcomes := make([]*GameOutcome, len(played))
|
||||
for i, p := range played {
|
||||
outcomes[i] = buildGameOutcome(teamID, p.fixture, p.result)
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
// buildGameOutcome determines the outcome type for a single game from a team's perspective.
|
||||
// Note: fixtures must have their HomeTeam and AwayTeam relations loaded.
|
||||
func buildGameOutcome(teamID int, fixture *Fixture, result *FixtureResult) *GameOutcome {
|
||||
isHome := fixture.HomeTeamID == teamID
|
||||
var opponent *Team
|
||||
if isHome {
|
||||
opponent = fixture.AwayTeam // may be nil if relation not loaded
|
||||
} else {
|
||||
opponent = fixture.HomeTeam // may be nil if relation not loaded
|
||||
}
|
||||
|
||||
outcome := &GameOutcome{
|
||||
Opponent: opponent,
|
||||
Fixture: fixture,
|
||||
}
|
||||
|
||||
// Handle forfeits
|
||||
if result.IsForfeit {
|
||||
outcome.IsForfeit = true
|
||||
outcome.Type = OutcomeForfeit
|
||||
if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeMutual {
|
||||
outcome.Type = OutcomeOTLoss // mutual forfeit counts as OT loss for both
|
||||
} else if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeOutright {
|
||||
thisSide := "away"
|
||||
if isHome {
|
||||
thisSide = "home"
|
||||
}
|
||||
if result.ForfeitTeam != nil && *result.ForfeitTeam == thisSide {
|
||||
outcome.Type = OutcomeLoss // this team forfeited
|
||||
} else {
|
||||
outcome.Type = OutcomeWin // opponent forfeited
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
// Normal match - build score string from this team's perspective
|
||||
if isHome {
|
||||
outcome.Score = fmt.Sprintf("%d-%d", result.HomeScore, result.AwayScore)
|
||||
} else {
|
||||
outcome.Score = fmt.Sprintf("%d-%d", result.AwayScore, result.HomeScore)
|
||||
}
|
||||
|
||||
won := (isHome && result.Winner == "home") || (!isHome && result.Winner == "away")
|
||||
lost := (isHome && result.Winner == "away") || (!isHome && result.Winner == "home")
|
||||
isOT := strings.EqualFold(result.EndReason, "Overtime")
|
||||
|
||||
switch {
|
||||
case won && isOT:
|
||||
outcome.Type = OutcomeOTWin
|
||||
case won:
|
||||
outcome.Type = OutcomeWin
|
||||
case lost && isOT:
|
||||
outcome.Type = OutcomeOTLoss
|
||||
case lost:
|
||||
outcome.Type = OutcomeLoss
|
||||
default:
|
||||
outcome.Type = OutcomeDraw
|
||||
}
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
// GetTeamsForSeasonLeague returns all teams participating in a given season+league.
|
||||
func GetTeamsForSeasonLeague(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Team, error) {
|
||||
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 = ?", seasonID, leagueID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
// ComputeMatchPreview fetches all data needed for the match preview tab:
|
||||
// team standings, positions, and recent game outcomes for both teams.
|
||||
func ComputeMatchPreview(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixture *Fixture,
|
||||
) (*MatchPreviewData, error) {
|
||||
if fixture == nil {
|
||||
return nil, errors.New("fixture cannot be nil")
|
||||
}
|
||||
|
||||
// Get all teams in this season+league
|
||||
allTeams, err := GetTeamsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetTeamsForSeasonLeague")
|
||||
}
|
||||
|
||||
// Get all allocated fixtures for the season+league
|
||||
allFixtures, err := GetAllocatedFixtures(ctx, tx, fixture.SeasonID, fixture.LeagueID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetAllocatedFixtures")
|
||||
}
|
||||
|
||||
// Get finalized results
|
||||
allFixtureIDs := make([]int, len(allFixtures))
|
||||
for i, f := range allFixtures {
|
||||
allFixtureIDs[i] = f.ID
|
||||
}
|
||||
allResultMap, err := GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures")
|
||||
}
|
||||
|
||||
// Get accepted schedules for ordering recent games
|
||||
allScheduleMap, err := GetAcceptedSchedulesForFixtures(ctx, tx, allFixtureIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetAcceptedSchedulesForFixtures")
|
||||
}
|
||||
|
||||
// Compute leaderboard
|
||||
leaderboard := ComputeLeaderboard(allTeams, allFixtures, allResultMap)
|
||||
|
||||
// Extract positions and records for both teams
|
||||
preview := &MatchPreviewData{
|
||||
TotalTeams: len(leaderboard),
|
||||
}
|
||||
for _, entry := range leaderboard {
|
||||
if entry.Team.ID == fixture.HomeTeamID {
|
||||
preview.HomePosition = entry.Position
|
||||
preview.HomeRecord = entry.Record
|
||||
}
|
||||
if entry.Team.ID == fixture.AwayTeamID {
|
||||
preview.AwayPosition = entry.Position
|
||||
preview.AwayRecord = entry.Record
|
||||
}
|
||||
}
|
||||
if preview.HomeRecord == nil {
|
||||
preview.HomeRecord = &TeamRecord{}
|
||||
}
|
||||
if preview.AwayRecord == nil {
|
||||
preview.AwayRecord = &TeamRecord{}
|
||||
}
|
||||
|
||||
// Compute recent games (last 5) for each team
|
||||
preview.HomeRecentGames = ComputeRecentGames(
|
||||
fixture.HomeTeamID, allFixtures, allResultMap, allScheduleMap, 5,
|
||||
)
|
||||
preview.AwayRecentGames = ComputeRecentGames(
|
||||
fixture.AwayTeamID, allFixtures, allResultMap, allScheduleMap, 5,
|
||||
)
|
||||
|
||||
return preview, nil
|
||||
}
|
||||
@@ -35,6 +35,8 @@
|
||||
--text-3xl--line-height: calc(2.25 / 1.875);
|
||||
--text-4xl: 2.25rem;
|
||||
--text-4xl--line-height: calc(2.5 / 2.25);
|
||||
--text-5xl: 3rem;
|
||||
--text-5xl--line-height: 1;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-6xl--line-height: 1;
|
||||
--text-9xl: 8rem;
|
||||
@@ -47,6 +49,7 @@
|
||||
--tracking-tight: -0.025em;
|
||||
--tracking-wider: 0.05em;
|
||||
--leading-relaxed: 1.625;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
@@ -450,6 +453,9 @@
|
||||
.h-3 {
|
||||
height: calc(var(--spacing) * 3);
|
||||
}
|
||||
.h-3\.5 {
|
||||
height: calc(var(--spacing) * 3.5);
|
||||
}
|
||||
.h-4 {
|
||||
height: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -459,9 +465,15 @@
|
||||
.h-6 {
|
||||
height: calc(var(--spacing) * 6);
|
||||
}
|
||||
.h-9 {
|
||||
height: calc(var(--spacing) * 9);
|
||||
}
|
||||
.h-12 {
|
||||
height: calc(var(--spacing) * 12);
|
||||
}
|
||||
.h-14 {
|
||||
height: calc(var(--spacing) * 14);
|
||||
}
|
||||
.h-16 {
|
||||
height: calc(var(--spacing) * 16);
|
||||
}
|
||||
@@ -510,6 +522,9 @@
|
||||
.w-3 {
|
||||
width: calc(var(--spacing) * 3);
|
||||
}
|
||||
.w-3\.5 {
|
||||
width: calc(var(--spacing) * 3.5);
|
||||
}
|
||||
.w-4 {
|
||||
width: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -519,18 +534,30 @@
|
||||
.w-6 {
|
||||
width: calc(var(--spacing) * 6);
|
||||
}
|
||||
.w-8 {
|
||||
width: calc(var(--spacing) * 8);
|
||||
}
|
||||
.w-9 {
|
||||
width: calc(var(--spacing) * 9);
|
||||
}
|
||||
.w-10 {
|
||||
width: calc(var(--spacing) * 10);
|
||||
}
|
||||
.w-12 {
|
||||
width: calc(var(--spacing) * 12);
|
||||
}
|
||||
.w-14 {
|
||||
width: calc(var(--spacing) * 14);
|
||||
}
|
||||
.w-20 {
|
||||
width: calc(var(--spacing) * 20);
|
||||
}
|
||||
.w-26 {
|
||||
width: calc(var(--spacing) * 26);
|
||||
}
|
||||
.w-28 {
|
||||
width: calc(var(--spacing) * 28);
|
||||
}
|
||||
.w-48 {
|
||||
width: calc(var(--spacing) * 48);
|
||||
}
|
||||
@@ -636,6 +663,9 @@
|
||||
.animate-spin {
|
||||
animation: var(--animate-spin);
|
||||
}
|
||||
.cursor-default {
|
||||
cursor: default;
|
||||
}
|
||||
.cursor-grab {
|
||||
cursor: grab;
|
||||
}
|
||||
@@ -729,6 +759,13 @@
|
||||
.gap-8 {
|
||||
gap: calc(var(--spacing) * 8);
|
||||
}
|
||||
.space-y-0 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-block-start: calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));
|
||||
margin-block-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
.space-y-0\.5 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
@@ -743,6 +780,13 @@
|
||||
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
.space-y-1\.5 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-block-start: calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));
|
||||
margin-block-end: calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
.space-y-2 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
@@ -867,6 +911,9 @@
|
||||
.rounded-lg {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.rounded-md {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.rounded-xl {
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
@@ -1012,6 +1059,12 @@
|
||||
.bg-green {
|
||||
background-color: var(--green);
|
||||
}
|
||||
.bg-green\/10 {
|
||||
background-color: var(--green);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--green) 10%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-green\/20 {
|
||||
background-color: var(--green);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -1030,6 +1083,18 @@
|
||||
.bg-mauve {
|
||||
background-color: var(--mauve);
|
||||
}
|
||||
.bg-overlay0\/10 {
|
||||
background-color: var(--overlay0);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--overlay0) 10%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-overlay0\/20 {
|
||||
background-color: var(--overlay0);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--overlay0) 20%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-peach {
|
||||
background-color: var(--peach);
|
||||
}
|
||||
@@ -1039,6 +1104,12 @@
|
||||
background-color: color-mix(in oklab, var(--peach) 5%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-peach\/10 {
|
||||
background-color: var(--peach);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--peach) 10%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-peach\/20 {
|
||||
background-color: var(--peach);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -1060,12 +1131,24 @@
|
||||
background-color: color-mix(in oklab, var(--red) 10%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-red\/15 {
|
||||
background-color: var(--red);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--red) 15%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-red\/20 {
|
||||
background-color: var(--red);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--red) 20%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-red\/30 {
|
||||
background-color: var(--red);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--red) 30%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-sapphire {
|
||||
background-color: var(--sapphire);
|
||||
}
|
||||
@@ -1123,6 +1206,9 @@
|
||||
.p-8 {
|
||||
padding: calc(var(--spacing) * 8);
|
||||
}
|
||||
.px-1 {
|
||||
padding-inline: calc(var(--spacing) * 1);
|
||||
}
|
||||
.px-1\.5 {
|
||||
padding-inline: calc(var(--spacing) * 1.5);
|
||||
}
|
||||
@@ -1156,6 +1242,9 @@
|
||||
.py-2 {
|
||||
padding-block: calc(var(--spacing) * 2);
|
||||
}
|
||||
.py-2\.5 {
|
||||
padding-block: calc(var(--spacing) * 2.5);
|
||||
}
|
||||
.py-3 {
|
||||
padding-block: calc(var(--spacing) * 3);
|
||||
}
|
||||
@@ -1189,6 +1278,9 @@
|
||||
.pr-2 {
|
||||
padding-right: calc(var(--spacing) * 2);
|
||||
}
|
||||
.pr-4 {
|
||||
padding-right: calc(var(--spacing) * 4);
|
||||
}
|
||||
.pr-10 {
|
||||
padding-right: calc(var(--spacing) * 10);
|
||||
}
|
||||
@@ -1204,6 +1296,9 @@
|
||||
.pl-3 {
|
||||
padding-left: calc(var(--spacing) * 3);
|
||||
}
|
||||
.pl-4 {
|
||||
padding-left: calc(var(--spacing) * 4);
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1228,6 +1323,10 @@
|
||||
font-size: var(--text-4xl);
|
||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
||||
}
|
||||
.text-5xl {
|
||||
font-size: var(--text-5xl);
|
||||
line-height: var(--tw-leading, var(--text-5xl--line-height));
|
||||
}
|
||||
.text-9xl {
|
||||
font-size: var(--text-9xl);
|
||||
line-height: var(--tw-leading, var(--text-9xl--line-height));
|
||||
@@ -1544,6 +1643,12 @@
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
}
|
||||
.last\:border-b-0 {
|
||||
&:last-child {
|
||||
border-bottom-style: var(--tw-border-style);
|
||||
border-bottom-width: 0px;
|
||||
}
|
||||
}
|
||||
.hover\:-translate-y-0\.5 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -2026,6 +2131,11 @@
|
||||
width: calc(var(--spacing) * 10);
|
||||
}
|
||||
}
|
||||
.sm\:w-36 {
|
||||
@media (width >= 40rem) {
|
||||
width: calc(var(--spacing) * 36);
|
||||
}
|
||||
}
|
||||
.sm\:w-auto {
|
||||
@media (width >= 40rem) {
|
||||
width: auto;
|
||||
@@ -2098,6 +2208,16 @@
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
}
|
||||
.sm\:gap-8 {
|
||||
@media (width >= 40rem) {
|
||||
gap: calc(var(--spacing) * 8);
|
||||
}
|
||||
}
|
||||
.sm\:gap-10 {
|
||||
@media (width >= 40rem) {
|
||||
gap: calc(var(--spacing) * 10);
|
||||
}
|
||||
}
|
||||
.sm\:p-6 {
|
||||
@media (width >= 40rem) {
|
||||
padding: calc(var(--spacing) * 6);
|
||||
@@ -2128,12 +2248,30 @@
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
.sm\:text-2xl {
|
||||
@media (width >= 40rem) {
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--tw-leading, var(--text-2xl--line-height));
|
||||
}
|
||||
}
|
||||
.sm\:text-4xl {
|
||||
@media (width >= 40rem) {
|
||||
font-size: var(--text-4xl);
|
||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
||||
}
|
||||
}
|
||||
.sm\:text-6xl {
|
||||
@media (width >= 40rem) {
|
||||
font-size: var(--text-6xl);
|
||||
line-height: var(--tw-leading, var(--text-6xl--line-height));
|
||||
}
|
||||
}
|
||||
.sm\:text-xl {
|
||||
@media (width >= 40rem) {
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--tw-leading, var(--text-xl--line-height));
|
||||
}
|
||||
}
|
||||
.md\:col-span-2 {
|
||||
@media (width >= 48rem) {
|
||||
grid-column: span 2 / span 2;
|
||||
|
||||
@@ -47,6 +47,7 @@ func FixtureDetailPage(
|
||||
var rosters map[string][]*db.PlayerWithPlayStatus
|
||||
var nominatedFreeAgents []*db.FixtureFreeAgent
|
||||
var availableFreeAgents []*db.SeasonLeagueFreeAgent
|
||||
var previewData *db.MatchPreviewData
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
@@ -94,6 +95,15 @@ func FixtureDetailPage(
|
||||
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch match preview data for preview and analysis tabs
|
||||
if activeTab == "preview" || activeTab == "analysis" {
|
||||
previewData, err = db.ComputeMatchPreview(ctx, tx, fixture)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.ComputeMatchPreview")
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
@@ -102,6 +112,7 @@ func FixtureDetailPage(
|
||||
renderSafely(seasonsview.FixtureDetailPage(
|
||||
fixture, currentSchedule, history, canSchedule, userTeamID,
|
||||
result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents,
|
||||
previewData,
|
||||
), s, r, w)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ templ FixtureDetailPage(
|
||||
activeTab string,
|
||||
nominatedFreeAgents []*db.FixtureFreeAgent,
|
||||
availableFreeAgents []*db.SeasonLeagueFreeAgent,
|
||||
previewData *db.MatchPreviewData,
|
||||
) {
|
||||
{{
|
||||
permCache := contexts.Permissions(ctx)
|
||||
@@ -33,6 +34,14 @@ templ FixtureDetailPage(
|
||||
if isFinalized && activeTab == "schedule" {
|
||||
activeTab = "overview"
|
||||
}
|
||||
// Redirect preview → analysis once finalized
|
||||
if isFinalized && activeTab == "preview" {
|
||||
activeTab = "analysis"
|
||||
}
|
||||
// Redirect analysis → preview if not finalized
|
||||
if !isFinalized && activeTab == "analysis" {
|
||||
activeTab = "preview"
|
||||
}
|
||||
}}
|
||||
@baseview.Layout(fmt.Sprintf("%s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
|
||||
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
||||
@@ -71,19 +80,26 @@ templ FixtureDetailPage(
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab Navigation (hidden when only one tab) -->
|
||||
if !isFinalized {
|
||||
<nav class="bg-surface0 border-b border-surface1">
|
||||
<ul class="flex flex-wrap">
|
||||
@fixtureTabItem("overview", "Overview", activeTab, fixture)
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="bg-surface0 border-b border-surface1">
|
||||
<ul class="flex flex-wrap">
|
||||
@fixtureTabItem("overview", "Overview", activeTab, fixture)
|
||||
if isFinalized {
|
||||
@fixtureTabItem("analysis", "Match Analysis", activeTab, fixture)
|
||||
} else {
|
||||
@fixtureTabItem("preview", "Match Preview", activeTab, fixture)
|
||||
@fixtureTabItem("schedule", "Schedule", activeTab, fixture)
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<!-- Tab Content -->
|
||||
if activeTab == "overview" {
|
||||
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents)
|
||||
} else if activeTab == "preview" && previewData != nil {
|
||||
@fixtureMatchPreviewTab(fixture, rosters, previewData)
|
||||
} else if activeTab == "analysis" && result != nil && result.Finalized {
|
||||
@fixtureMatchAnalysisTab(fixture, result, rosters, previewData)
|
||||
} else if activeTab == "schedule" {
|
||||
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
|
||||
}
|
||||
|
||||
611
internal/view/seasonsview/fixture_match_analysis.templ
Normal file
611
internal/view/seasonsview/fixture_match_analysis.templ
Normal file
@@ -0,0 +1,611 @@
|
||||
package seasonsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||
import "fmt"
|
||||
import "sort"
|
||||
import "strings"
|
||||
|
||||
// teamAggStats holds aggregated stats for a single team in a fixture.
|
||||
type teamAggStats struct {
|
||||
Goals int
|
||||
Assists int
|
||||
PrimaryAssists int
|
||||
SecondaryAssists int
|
||||
Saves int
|
||||
Shots int
|
||||
Blocks int
|
||||
Passes int
|
||||
Turnovers int
|
||||
Takeaways int
|
||||
FaceoffsWon int
|
||||
FaceoffsLost int
|
||||
PostHits int
|
||||
PossessionSec int
|
||||
PlayersUsed int
|
||||
}
|
||||
|
||||
func aggregateTeamStats(players []*db.PlayerWithPlayStatus) *teamAggStats {
|
||||
agg := &teamAggStats{}
|
||||
for _, p := range players {
|
||||
if !p.Played || p.Stats == nil {
|
||||
continue
|
||||
}
|
||||
agg.PlayersUsed++
|
||||
if p.Stats.Goals != nil {
|
||||
agg.Goals += *p.Stats.Goals
|
||||
}
|
||||
if p.Stats.Assists != nil {
|
||||
agg.Assists += *p.Stats.Assists
|
||||
}
|
||||
if p.Stats.PrimaryAssists != nil {
|
||||
agg.PrimaryAssists += *p.Stats.PrimaryAssists
|
||||
}
|
||||
if p.Stats.SecondaryAssists != nil {
|
||||
agg.SecondaryAssists += *p.Stats.SecondaryAssists
|
||||
}
|
||||
if p.Stats.Saves != nil {
|
||||
agg.Saves += *p.Stats.Saves
|
||||
}
|
||||
if p.Stats.Shots != nil {
|
||||
agg.Shots += *p.Stats.Shots
|
||||
}
|
||||
if p.Stats.Blocks != nil {
|
||||
agg.Blocks += *p.Stats.Blocks
|
||||
}
|
||||
if p.Stats.Passes != nil {
|
||||
agg.Passes += *p.Stats.Passes
|
||||
}
|
||||
if p.Stats.Turnovers != nil {
|
||||
agg.Turnovers += *p.Stats.Turnovers
|
||||
}
|
||||
if p.Stats.Takeaways != nil {
|
||||
agg.Takeaways += *p.Stats.Takeaways
|
||||
}
|
||||
if p.Stats.FaceoffsWon != nil {
|
||||
agg.FaceoffsWon += *p.Stats.FaceoffsWon
|
||||
}
|
||||
if p.Stats.FaceoffsLost != nil {
|
||||
agg.FaceoffsLost += *p.Stats.FaceoffsLost
|
||||
}
|
||||
if p.Stats.PostHits != nil {
|
||||
agg.PostHits += *p.Stats.PostHits
|
||||
}
|
||||
if p.Stats.PossessionTimeSec != nil {
|
||||
agg.PossessionSec += *p.Stats.PossessionTimeSec
|
||||
}
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
func formatPossession(seconds int) string {
|
||||
m := seconds / 60
|
||||
s := seconds % 60
|
||||
return fmt.Sprintf("%d:%02d", m, s)
|
||||
}
|
||||
|
||||
func faceoffPct(won, lost int) string {
|
||||
total := won + lost
|
||||
if total == 0 {
|
||||
return "0%"
|
||||
}
|
||||
pct := float64(won) / float64(total) * 100
|
||||
return fmt.Sprintf("%.0f%%", pct)
|
||||
}
|
||||
|
||||
// fixtureMatchAnalysisTab renders the full Match Analysis tab for completed fixtures.
|
||||
// Shows score, team stats comparison, match details, and top performers.
|
||||
templ fixtureMatchAnalysisTab(
|
||||
fixture *db.Fixture,
|
||||
result *db.FixtureResult,
|
||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||
preview *db.MatchPreviewData,
|
||||
) {
|
||||
<div class="space-y-6">
|
||||
<!-- Score Display -->
|
||||
@analysisScoreHeader(fixture, result)
|
||||
|
||||
<!-- Team Stats Comparison -->
|
||||
@analysisTeamStatsComparison(fixture, rosters)
|
||||
|
||||
<!-- Top Performers -->
|
||||
@analysisTopPerformers(fixture, rosters)
|
||||
|
||||
<!-- Standings Context (from preview data) -->
|
||||
if preview != nil {
|
||||
@analysisStandingsContext(fixture, preview)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// analysisScoreHeader renders the final score in a prominent broadcast-style display.
|
||||
templ analysisScoreHeader(fixture *db.Fixture, result *db.FixtureResult) {
|
||||
{{
|
||||
isOT := strings.EqualFold(result.EndReason, "Overtime")
|
||||
homeWon := result.Winner == "home"
|
||||
awayWon := result.Winner == "away"
|
||||
isForfeit := result.IsForfeit
|
||||
}}
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Final Score</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
if isForfeit {
|
||||
@analysisForfeitDisplay(fixture, result)
|
||||
} else {
|
||||
<div class="flex items-center justify-center gap-6 sm:gap-10">
|
||||
<!-- Home Team -->
|
||||
<div class="flex flex-col items-center text-center flex-1">
|
||||
if fixture.HomeTeam.Color != "" {
|
||||
<div
|
||||
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||
></div>
|
||||
}
|
||||
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
|
||||
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
|
||||
</h3>
|
||||
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", homeWon), templ.KV("text-text", !homeWon) }>
|
||||
{ fmt.Sprint(result.HomeScore) }
|
||||
</span>
|
||||
if homeWon {
|
||||
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Divider -->
|
||||
<div class="flex flex-col items-center shrink-0">
|
||||
<span class="text-4xl text-subtext0 font-light">–</span>
|
||||
if isOT {
|
||||
<span class="mt-1 px-2 py-0.5 bg-peach/20 text-peach rounded text-xs font-bold">OT</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Away Team -->
|
||||
<div class="flex flex-col items-center text-center flex-1">
|
||||
if fixture.AwayTeam.Color != "" {
|
||||
<div
|
||||
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||
></div>
|
||||
}
|
||||
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
|
||||
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
|
||||
</h3>
|
||||
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", awayWon), templ.KV("text-text", !awayWon) }>
|
||||
{ fmt.Sprint(result.AwayScore) }
|
||||
</span>
|
||||
if awayWon {
|
||||
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// analysisForfeitDisplay renders a forfeit result in the analysis header.
|
||||
templ analysisForfeitDisplay(fixture *db.Fixture, result *db.FixtureResult) {
|
||||
{{
|
||||
isMutualForfeit := result.ForfeitType != nil && *result.ForfeitType == "mutual"
|
||||
isOutrightForfeit := result.ForfeitType != nil && *result.ForfeitType == "outright"
|
||||
forfeitTeamName := ""
|
||||
winnerTeamName := ""
|
||||
if isOutrightForfeit && result.ForfeitTeam != nil {
|
||||
if *result.ForfeitTeam == "home" {
|
||||
forfeitTeamName = fixture.HomeTeam.Name
|
||||
winnerTeamName = fixture.AwayTeam.Name
|
||||
} else {
|
||||
forfeitTeamName = fixture.AwayTeam.Name
|
||||
winnerTeamName = fixture.HomeTeam.Name
|
||||
}
|
||||
}
|
||||
}}
|
||||
<div class="flex flex-col items-center py-4 space-y-4">
|
||||
if isMutualForfeit {
|
||||
<span class="px-4 py-2 bg-peach/20 text-peach rounded-lg text-lg font-bold">MUTUAL FORFEIT</span>
|
||||
<p class="text-sm text-subtext0">Both teams receive an overtime loss</p>
|
||||
} else if isOutrightForfeit {
|
||||
<span class="px-4 py-2 bg-red/20 text-red rounded-lg text-lg font-bold">FORFEIT</span>
|
||||
<p class="text-sm text-subtext0">
|
||||
{ forfeitTeamName } forfeited — { winnerTeamName } wins
|
||||
</p>
|
||||
}
|
||||
if result.ForfeitReason != nil && *result.ForfeitReason != "" {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-3 max-w-md w-full text-center">
|
||||
<p class="text-xs text-subtext1 font-medium mb-1">Reason</p>
|
||||
<p class="text-sm text-subtext0">{ *result.ForfeitReason }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// analysisTeamStatsComparison renders aggregated team stats in the broadcast comparison layout.
|
||||
templ analysisTeamStatsComparison(fixture *db.Fixture, rosters map[string][]*db.PlayerWithPlayStatus) {
|
||||
{{
|
||||
homeAgg := aggregateTeamStats(rosters["home"])
|
||||
awayAgg := aggregateTeamStats(rosters["away"])
|
||||
}}
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Team Statistics</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- Team Name Headers -->
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-1 text-right pr-4">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
if fixture.HomeTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
<span class="text-sm font-bold text-text">{ fixture.HomeTeam.ShortName }</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-28 sm:w-36 text-center shrink-0"></div>
|
||||
<div class="flex-1 text-left pl-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-text">{ fixture.AwayTeam.ShortName }</span>
|
||||
if fixture.AwayTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stats Rows -->
|
||||
<div class="space-y-0">
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Goals),
|
||||
"Goals",
|
||||
fmt.Sprint(awayAgg.Goals),
|
||||
homeAgg.Goals > awayAgg.Goals,
|
||||
awayAgg.Goals > homeAgg.Goals,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Assists),
|
||||
"Assists",
|
||||
fmt.Sprint(awayAgg.Assists),
|
||||
homeAgg.Assists > awayAgg.Assists,
|
||||
awayAgg.Assists > homeAgg.Assists,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Shots),
|
||||
"Shots",
|
||||
fmt.Sprint(awayAgg.Shots),
|
||||
homeAgg.Shots > awayAgg.Shots,
|
||||
awayAgg.Shots > homeAgg.Shots,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Saves),
|
||||
"Saves",
|
||||
fmt.Sprint(awayAgg.Saves),
|
||||
homeAgg.Saves > awayAgg.Saves,
|
||||
awayAgg.Saves > homeAgg.Saves,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Blocks),
|
||||
"Blocks",
|
||||
fmt.Sprint(awayAgg.Blocks),
|
||||
homeAgg.Blocks > awayAgg.Blocks,
|
||||
awayAgg.Blocks > homeAgg.Blocks,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Passes),
|
||||
"Passes",
|
||||
fmt.Sprint(awayAgg.Passes),
|
||||
homeAgg.Passes > awayAgg.Passes,
|
||||
awayAgg.Passes > homeAgg.Passes,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Takeaways),
|
||||
"Takeaways",
|
||||
fmt.Sprint(awayAgg.Takeaways),
|
||||
homeAgg.Takeaways > awayAgg.Takeaways,
|
||||
awayAgg.Takeaways > homeAgg.Takeaways,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.Turnovers),
|
||||
"Turnovers",
|
||||
fmt.Sprint(awayAgg.Turnovers),
|
||||
homeAgg.Turnovers < awayAgg.Turnovers,
|
||||
awayAgg.Turnovers < homeAgg.Turnovers,
|
||||
)
|
||||
<!-- Faceoffs -->
|
||||
{{
|
||||
homeFO := homeAgg.FaceoffsWon + homeAgg.FaceoffsLost
|
||||
awayFO := awayAgg.FaceoffsWon + awayAgg.FaceoffsLost
|
||||
homeFOStr := fmt.Sprintf("%d/%d", homeAgg.FaceoffsWon, homeFO)
|
||||
awayFOStr := fmt.Sprintf("%d/%d", awayAgg.FaceoffsWon, awayFO)
|
||||
}}
|
||||
@previewStatRow(
|
||||
homeFOStr,
|
||||
"Faceoffs Won",
|
||||
awayFOStr,
|
||||
homeAgg.FaceoffsWon > awayAgg.FaceoffsWon,
|
||||
awayAgg.FaceoffsWon > homeAgg.FaceoffsWon,
|
||||
)
|
||||
@previewStatRow(
|
||||
faceoffPct(homeAgg.FaceoffsWon, homeAgg.FaceoffsLost),
|
||||
"Faceoff %",
|
||||
faceoffPct(awayAgg.FaceoffsWon, awayAgg.FaceoffsLost),
|
||||
homeAgg.FaceoffsWon * (awayAgg.FaceoffsWon + awayAgg.FaceoffsLost) > awayAgg.FaceoffsWon * (homeAgg.FaceoffsWon + homeAgg.FaceoffsLost),
|
||||
awayAgg.FaceoffsWon * (homeAgg.FaceoffsWon + homeAgg.FaceoffsLost) > homeAgg.FaceoffsWon * (awayAgg.FaceoffsWon + awayAgg.FaceoffsLost),
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.PostHits),
|
||||
"Post Hits",
|
||||
fmt.Sprint(awayAgg.PostHits),
|
||||
homeAgg.PostHits > awayAgg.PostHits,
|
||||
awayAgg.PostHits > homeAgg.PostHits,
|
||||
)
|
||||
@previewStatRow(
|
||||
formatPossession(homeAgg.PossessionSec),
|
||||
"Possession",
|
||||
formatPossession(awayAgg.PossessionSec),
|
||||
homeAgg.PossessionSec > awayAgg.PossessionSec,
|
||||
awayAgg.PossessionSec > homeAgg.PossessionSec,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(homeAgg.PlayersUsed),
|
||||
"Players Used",
|
||||
fmt.Sprint(awayAgg.PlayersUsed),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// analysisTopPerformers shows the top players from each team based on score.
|
||||
templ analysisTopPerformers(fixture *db.Fixture, rosters map[string][]*db.PlayerWithPlayStatus) {
|
||||
{{
|
||||
// Collect players who played and have stats, sorted by score descending
|
||||
type scoredPlayer struct {
|
||||
Player *db.Player
|
||||
Stats *db.FixtureResultPlayerStats
|
||||
IsManager bool
|
||||
IsFreeAgent bool
|
||||
}
|
||||
|
||||
collectTop := func(players []*db.PlayerWithPlayStatus, limit int) []*scoredPlayer {
|
||||
var scored []*scoredPlayer
|
||||
for _, p := range players {
|
||||
if !p.Played || p.Stats == nil || p.Player == nil {
|
||||
continue
|
||||
}
|
||||
scored = append(scored, &scoredPlayer{
|
||||
Player: p.Player,
|
||||
Stats: p.Stats,
|
||||
IsManager: p.IsManager,
|
||||
IsFreeAgent: p.IsFreeAgent,
|
||||
})
|
||||
}
|
||||
sort.Slice(scored, func(i, j int) bool {
|
||||
si, sj := 0, 0
|
||||
if scored[i].Stats.Score != nil {
|
||||
si = *scored[i].Stats.Score
|
||||
}
|
||||
if scored[j].Stats.Score != nil {
|
||||
sj = *scored[j].Stats.Score
|
||||
}
|
||||
return si > sj
|
||||
})
|
||||
if len(scored) > limit {
|
||||
scored = scored[:limit]
|
||||
}
|
||||
return scored
|
||||
}
|
||||
|
||||
homeTop := collectTop(rosters["home"], 3)
|
||||
awayTop := collectTop(rosters["away"], 3)
|
||||
}}
|
||||
if len(homeTop) > 0 || len(awayTop) > 0 {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Top Performers</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Home Top Performers -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
if fixture.HomeTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
<h3 class="text-md font-bold text-text">{ fixture.HomeTeam.Name }</h3>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
for i, p := range homeTop {
|
||||
@topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Away Top Performers -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
if fixture.AwayTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
<h3 class="text-md font-bold text-text">{ fixture.AwayTeam.Name }</h3>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
for i, p := range awayTop {
|
||||
@topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// topPerformerCard renders a single top performer card with key stats.
|
||||
templ topPerformerCard(player *db.Player, stats *db.FixtureResultPlayerStats, isManager bool, isFreeAgent bool, rank int) {
|
||||
{{
|
||||
rankLabels := map[int]string{1: "🥇", 2: "🥈", 3: "🥉"}
|
||||
rankLabel := rankLabels[rank]
|
||||
}}
|
||||
<div class="flex items-center gap-3 px-4 py-3 bg-surface0 border border-surface1 rounded-lg">
|
||||
<span class="text-lg shrink-0">{ rankLabel }</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium truncate">
|
||||
@links.PlayerLink(player)
|
||||
</span>
|
||||
if isManager {
|
||||
<span class="px-1 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium shrink-0">
|
||||
★
|
||||
</span>
|
||||
}
|
||||
if isFreeAgent {
|
||||
<span class="px-1 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium shrink-0">
|
||||
FA
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-1 text-xs text-subtext0">
|
||||
if stats.Score != nil {
|
||||
<span title="Score"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Score) }</span> SC</span>
|
||||
}
|
||||
if stats.Goals != nil {
|
||||
<span title="Goals"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Goals) }</span> G</span>
|
||||
}
|
||||
if stats.Assists != nil {
|
||||
<span title="Assists"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Assists) }</span> A</span>
|
||||
}
|
||||
if stats.Saves != nil {
|
||||
<span title="Saves"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Saves) }</span> SV</span>
|
||||
}
|
||||
if stats.Shots != nil {
|
||||
<span title="Shots"><span class="font-semibold text-text">{ fmt.Sprint(*stats.Shots) }</span> SH</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// analysisStandingsContext shows how this result fits into the league standings.
|
||||
templ analysisStandingsContext(fixture *db.Fixture, preview *db.MatchPreviewData) {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">League Context</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- Team Name Headers -->
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-1 text-right pr-4">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
if fixture.HomeTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
<span class="text-sm font-bold text-text">{ fixture.HomeTeam.ShortName }</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-28 sm:w-36 text-center shrink-0"></div>
|
||||
<div class="flex-1 text-left pl-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-text">{ fixture.AwayTeam.ShortName }</span>
|
||||
if fixture.AwayTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-0">
|
||||
{{
|
||||
homePos := ordinal(preview.HomePosition)
|
||||
awayPos := ordinal(preview.AwayPosition)
|
||||
if preview.HomePosition == 0 {
|
||||
homePos = "N/A"
|
||||
}
|
||||
if preview.AwayPosition == 0 {
|
||||
awayPos = "N/A"
|
||||
}
|
||||
}}
|
||||
@previewStatRow(
|
||||
homePos,
|
||||
"Position",
|
||||
awayPos,
|
||||
preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition,
|
||||
preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.Points),
|
||||
"Points",
|
||||
fmt.Sprint(preview.AwayRecord.Points),
|
||||
preview.HomeRecord.Points > preview.AwayRecord.Points,
|
||||
preview.AwayRecord.Points > preview.HomeRecord.Points,
|
||||
)
|
||||
@previewStatRow(
|
||||
fmt.Sprintf("%d-%d-%d-%d",
|
||||
preview.HomeRecord.Wins,
|
||||
preview.HomeRecord.OvertimeWins,
|
||||
preview.HomeRecord.OvertimeLosses,
|
||||
preview.HomeRecord.Losses,
|
||||
),
|
||||
"Record",
|
||||
fmt.Sprintf("%d-%d-%d-%d",
|
||||
preview.AwayRecord.Wins,
|
||||
preview.AwayRecord.OvertimeWins,
|
||||
preview.AwayRecord.OvertimeLosses,
|
||||
preview.AwayRecord.Losses,
|
||||
),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
{{
|
||||
homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst
|
||||
awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst
|
||||
}}
|
||||
@previewStatRow(
|
||||
fmt.Sprintf("%+d", homeDiff),
|
||||
"Goal Diff",
|
||||
fmt.Sprintf("%+d", awayDiff),
|
||||
homeDiff > awayDiff,
|
||||
awayDiff > homeDiff,
|
||||
)
|
||||
<!-- Recent Form -->
|
||||
if len(preview.HomeRecentGames) > 0 || len(preview.AwayRecentGames) > 0 {
|
||||
<div class="flex items-center py-3 border-b border-surface1 last:border-b-0">
|
||||
<div class="flex-1 flex justify-end pr-4">
|
||||
<div class="flex items-center gap-1">
|
||||
for _, g := range preview.HomeRecentGames {
|
||||
@gameOutcomeIcon(g)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-28 sm:w-36 text-center shrink-0">
|
||||
<span class="text-xs font-semibold text-subtext0 uppercase tracking-wider">Form</span>
|
||||
</div>
|
||||
<div class="flex-1 flex pl-4">
|
||||
<div class="flex items-center gap-1">
|
||||
for _, g := range preview.AwayRecentGames {
|
||||
@gameOutcomeIcon(g)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
435
internal/view/seasonsview/fixture_match_preview.templ
Normal file
435
internal/view/seasonsview/fixture_match_preview.templ
Normal file
@@ -0,0 +1,435 @@
|
||||
package seasonsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||
import "fmt"
|
||||
import "sort"
|
||||
|
||||
// fixtureMatchPreviewTab renders the full Match Preview tab content.
|
||||
// Shows team standings comparison, recent form, and full rosters side-by-side.
|
||||
templ fixtureMatchPreviewTab(
|
||||
fixture *db.Fixture,
|
||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||
preview *db.MatchPreviewData,
|
||||
) {
|
||||
<div class="space-y-6">
|
||||
<!-- Team Comparison Header -->
|
||||
@matchPreviewHeader(fixture, preview)
|
||||
|
||||
<!-- Form Guide (Last 5 Games) -->
|
||||
@matchPreviewFormGuide(fixture, preview)
|
||||
|
||||
<!-- Team Rosters -->
|
||||
@matchPreviewRosters(fixture, rosters)
|
||||
</div>
|
||||
}
|
||||
|
||||
// matchPreviewHeader renders the broadcast-style team comparison with standings.
|
||||
templ matchPreviewHeader(fixture *db.Fixture, preview *db.MatchPreviewData) {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Team Comparison</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- Team Names and VS -->
|
||||
<div class="flex items-center justify-center gap-4 sm:gap-8 mb-8">
|
||||
<!-- Home Team -->
|
||||
<div class="flex flex-col items-center text-center flex-1">
|
||||
if fixture.HomeTeam.Color != "" {
|
||||
<div
|
||||
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||
></div>
|
||||
}
|
||||
<h3 class="text-xl sm:text-2xl font-bold text-text">
|
||||
@links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League)
|
||||
</h3>
|
||||
<span class="text-subtext0 text-sm font-mono mt-1">{ fixture.HomeTeam.ShortName }</span>
|
||||
</div>
|
||||
<!-- VS Divider -->
|
||||
<div class="flex flex-col items-center shrink-0">
|
||||
<span class="text-3xl sm:text-4xl font-bold text-subtext0">VS</span>
|
||||
</div>
|
||||
<!-- Away Team -->
|
||||
<div class="flex flex-col items-center text-center flex-1">
|
||||
if fixture.AwayTeam.Color != "" {
|
||||
<div
|
||||
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||
></div>
|
||||
}
|
||||
<h3 class="text-xl sm:text-2xl font-bold text-text">
|
||||
@links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League)
|
||||
</h3>
|
||||
<span class="text-subtext0 text-sm font-mono mt-1">{ fixture.AwayTeam.ShortName }</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stats Comparison Grid -->
|
||||
{{
|
||||
homePos := ordinal(preview.HomePosition)
|
||||
awayPos := ordinal(preview.AwayPosition)
|
||||
if preview.HomePosition == 0 {
|
||||
homePos = "N/A"
|
||||
}
|
||||
if preview.AwayPosition == 0 {
|
||||
awayPos = "N/A"
|
||||
}
|
||||
}}
|
||||
<div class="space-y-0">
|
||||
<!-- Position -->
|
||||
@previewStatRow(
|
||||
homePos,
|
||||
"Position",
|
||||
awayPos,
|
||||
preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition,
|
||||
preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition,
|
||||
)
|
||||
<!-- Points -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.Points),
|
||||
"Points",
|
||||
fmt.Sprint(preview.AwayRecord.Points),
|
||||
preview.HomeRecord.Points > preview.AwayRecord.Points,
|
||||
preview.AwayRecord.Points > preview.HomeRecord.Points,
|
||||
)
|
||||
<!-- Played -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.Played),
|
||||
"Played",
|
||||
fmt.Sprint(preview.AwayRecord.Played),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
<!-- Wins -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.Wins),
|
||||
"Wins",
|
||||
fmt.Sprint(preview.AwayRecord.Wins),
|
||||
preview.HomeRecord.Wins > preview.AwayRecord.Wins,
|
||||
preview.AwayRecord.Wins > preview.HomeRecord.Wins,
|
||||
)
|
||||
<!-- OT Wins -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.OvertimeWins),
|
||||
"OT Wins",
|
||||
fmt.Sprint(preview.AwayRecord.OvertimeWins),
|
||||
preview.HomeRecord.OvertimeWins > preview.AwayRecord.OvertimeWins,
|
||||
preview.AwayRecord.OvertimeWins > preview.HomeRecord.OvertimeWins,
|
||||
)
|
||||
<!-- OT Losses -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.OvertimeLosses),
|
||||
"OT Losses",
|
||||
fmt.Sprint(preview.AwayRecord.OvertimeLosses),
|
||||
preview.HomeRecord.OvertimeLosses < preview.AwayRecord.OvertimeLosses,
|
||||
preview.AwayRecord.OvertimeLosses < preview.HomeRecord.OvertimeLosses,
|
||||
)
|
||||
<!-- Losses -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.Losses),
|
||||
"Losses",
|
||||
fmt.Sprint(preview.AwayRecord.Losses),
|
||||
preview.HomeRecord.Losses < preview.AwayRecord.Losses,
|
||||
preview.AwayRecord.Losses < preview.HomeRecord.Losses,
|
||||
)
|
||||
<!-- Goals For -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.GoalsFor),
|
||||
"Goals For",
|
||||
fmt.Sprint(preview.AwayRecord.GoalsFor),
|
||||
preview.HomeRecord.GoalsFor > preview.AwayRecord.GoalsFor,
|
||||
preview.AwayRecord.GoalsFor > preview.HomeRecord.GoalsFor,
|
||||
)
|
||||
<!-- Goals Against -->
|
||||
@previewStatRow(
|
||||
fmt.Sprint(preview.HomeRecord.GoalsAgainst),
|
||||
"Goals Against",
|
||||
fmt.Sprint(preview.AwayRecord.GoalsAgainst),
|
||||
preview.HomeRecord.GoalsAgainst < preview.AwayRecord.GoalsAgainst,
|
||||
preview.AwayRecord.GoalsAgainst < preview.HomeRecord.GoalsAgainst,
|
||||
)
|
||||
<!-- Goal Difference -->
|
||||
{{
|
||||
homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst
|
||||
awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst
|
||||
homeDiffStr := fmt.Sprintf("%+d", homeDiff)
|
||||
awayDiffStr := fmt.Sprintf("%+d", awayDiff)
|
||||
}}
|
||||
@previewStatRow(
|
||||
homeDiffStr,
|
||||
"Goal Diff",
|
||||
awayDiffStr,
|
||||
homeDiff > awayDiff,
|
||||
awayDiff > homeDiff,
|
||||
)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// previewStatRow renders a single comparison stat row in the broadcast-style layout.
|
||||
// The stat label is centered, with home value on the left and away value on the right.
|
||||
// homeHighlight/awayHighlight indicate which side has the better value.
|
||||
templ previewStatRow(homeValue, label, awayValue string, homeHighlight, awayHighlight bool) {
|
||||
<div class="flex items-center py-2.5 border-b border-surface1 last:border-b-0">
|
||||
<!-- Home Value -->
|
||||
<div class="flex-1 text-right pr-4">
|
||||
<span class={ "text-lg font-bold", templ.KV("text-green", homeHighlight), templ.KV("text-text", !homeHighlight) }>
|
||||
{ homeValue }
|
||||
</span>
|
||||
</div>
|
||||
<!-- Label -->
|
||||
<div class="w-28 sm:w-36 text-center shrink-0">
|
||||
<span class="text-xs font-semibold text-subtext0 uppercase tracking-wider">{ label }</span>
|
||||
</div>
|
||||
<!-- Away Value -->
|
||||
<div class="flex-1 text-left pl-4">
|
||||
<span class={ "text-lg font-bold", templ.KV("text-green", awayHighlight), templ.KV("text-text", !awayHighlight) }>
|
||||
{ awayValue }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// matchPreviewFormGuide renders the recent form section with last 5 game outcome icons.
|
||||
templ matchPreviewFormGuide(fixture *db.Fixture, preview *db.MatchPreviewData) {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Recent Form</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- Home Team Form -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
if fixture.HomeTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.HomeTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
<h3 class="text-md font-bold text-text">{ fixture.HomeTeam.Name }</h3>
|
||||
</div>
|
||||
if len(preview.HomeRecentGames) == 0 {
|
||||
<p class="text-subtext1 text-sm">No recent matches played</p>
|
||||
} else {
|
||||
<!-- Outcome Icons: chronological (oldest → newest, left → right) -->
|
||||
<div class="flex items-center gap-1.5 mb-4">
|
||||
for _, g := range preview.HomeRecentGames {
|
||||
@gameOutcomeIcon(g)
|
||||
}
|
||||
</div>
|
||||
<!-- Recent Results List: most recent first -->
|
||||
<div class="space-y-1.5">
|
||||
for i := len(preview.HomeRecentGames) - 1; i >= 0; i-- {
|
||||
@recentGameRow(preview.HomeRecentGames[i])
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Away Team Form -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
if fixture.AwayTeam.Color != "" {
|
||||
<span
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
style={ "background-color: " + templ.SafeCSS(fixture.AwayTeam.Color) }
|
||||
></span>
|
||||
}
|
||||
<h3 class="text-md font-bold text-text">{ fixture.AwayTeam.Name }</h3>
|
||||
</div>
|
||||
if len(preview.AwayRecentGames) == 0 {
|
||||
<p class="text-subtext1 text-sm">No recent matches played</p>
|
||||
} else {
|
||||
<!-- Outcome Icons: chronological (oldest → newest, left → right) -->
|
||||
<div class="flex items-center gap-1.5 mb-4">
|
||||
for _, g := range preview.AwayRecentGames {
|
||||
@gameOutcomeIcon(g)
|
||||
}
|
||||
</div>
|
||||
<!-- Recent Results List: most recent first -->
|
||||
<div class="space-y-1.5">
|
||||
for i := len(preview.AwayRecentGames) - 1; i >= 0; i-- {
|
||||
@recentGameRow(preview.AwayRecentGames[i])
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// outcomeStyle holds the styling info for a game outcome type.
|
||||
type outcomeStyle struct {
|
||||
iconBg string // Background class for the icon badge
|
||||
rowBg string // Background class for the row
|
||||
text string // Text color class
|
||||
label string // Short label (W, L, OW, OL, D, F)
|
||||
fullLabel string // Full label for row display (W, OTW, OTL, L, D, FF)
|
||||
desc string // Human-readable description (Win, Loss, etc.)
|
||||
}
|
||||
|
||||
func getOutcomeStyle(outcomeType string) outcomeStyle {
|
||||
switch outcomeType {
|
||||
case "W":
|
||||
return outcomeStyle{"bg-green/20", "bg-green/10", "text-green", "W", "W", "Win"}
|
||||
case "OTW":
|
||||
return outcomeStyle{"bg-yellow/20", "bg-yellow/10", "text-yellow", "OW", "OTW", "OT Win"}
|
||||
case "OTL":
|
||||
return outcomeStyle{"bg-peach/20", "bg-peach/10", "text-peach", "OL", "OTL", "OT Loss"}
|
||||
case "L":
|
||||
return outcomeStyle{"bg-red/20", "bg-red/10", "text-red", "L", "L", "Loss"}
|
||||
case "D":
|
||||
return outcomeStyle{"bg-overlay0/20", "bg-overlay0/10", "text-overlay0", "D", "D", "Draw"}
|
||||
case "F":
|
||||
return outcomeStyle{"bg-red/30", "bg-red/15", "text-red", "F", "FF", "Forfeit"}
|
||||
default:
|
||||
return outcomeStyle{"bg-surface1", "bg-surface0", "text-subtext0", "?", "?", "Unknown"}
|
||||
}
|
||||
}
|
||||
|
||||
// gameOutcomeIcon renders a single game outcome as a colored badge.
|
||||
templ gameOutcomeIcon(outcome *db.GameOutcome) {
|
||||
{{
|
||||
style := getOutcomeStyle(outcome.Type)
|
||||
tooltip := ""
|
||||
if outcome.Opponent != nil {
|
||||
tooltip = fmt.Sprintf("%s vs %s", style.desc, outcome.Opponent.Name)
|
||||
if outcome.IsForfeit {
|
||||
tooltip += " (Forfeit)"
|
||||
} else if outcome.Score != "" {
|
||||
tooltip += fmt.Sprintf(" (%s)", outcome.Score)
|
||||
}
|
||||
}
|
||||
}}
|
||||
<span
|
||||
class={ "inline-flex items-center justify-center w-9 h-9 rounded-md text-xs font-bold cursor-default", style.iconBg, style.text }
|
||||
title={ tooltip }
|
||||
>
|
||||
{ style.label }
|
||||
</span>
|
||||
}
|
||||
|
||||
// recentGameRow renders a single recent game result as a compact row.
|
||||
templ recentGameRow(outcome *db.GameOutcome) {
|
||||
{{
|
||||
style := getOutcomeStyle(outcome.Type)
|
||||
opponentName := "Unknown"
|
||||
if outcome.Opponent != nil {
|
||||
opponentName = outcome.Opponent.Name
|
||||
}
|
||||
}}
|
||||
<div class={ "flex items-center gap-3 px-3 py-2 rounded-lg", style.rowBg }>
|
||||
<span class={ "text-xs font-bold w-8 text-center", style.text }>{ style.fullLabel }</span>
|
||||
<span class="text-sm text-text">vs { opponentName }</span>
|
||||
if outcome.IsForfeit {
|
||||
<span class="text-xs text-red ml-auto font-medium">Forfeit</span>
|
||||
} else if outcome.Score != "" {
|
||||
<span class="text-sm text-subtext0 ml-auto font-mono">{ outcome.Score }</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// matchPreviewRosters renders team rosters side-by-side for the match preview.
|
||||
templ matchPreviewRosters(
|
||||
fixture *db.Fixture,
|
||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||
) {
|
||||
{{
|
||||
homePlayers := rosters["home"]
|
||||
awayPlayers := rosters["away"]
|
||||
}}
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Team Rosters</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Home Team Roster -->
|
||||
@previewRosterColumn(fixture.HomeTeam, homePlayers, fixture.Season, fixture.League)
|
||||
<!-- Away Team Roster -->
|
||||
@previewRosterColumn(fixture.AwayTeam, awayPlayers, fixture.Season, fixture.League)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// previewRosterColumn renders a single team's roster for the match preview.
|
||||
templ previewRosterColumn(
|
||||
team *db.Team,
|
||||
players []*db.PlayerWithPlayStatus,
|
||||
season *db.Season,
|
||||
league *db.League,
|
||||
) {
|
||||
{{
|
||||
// Separate managers and regular players
|
||||
var managers []*db.PlayerWithPlayStatus
|
||||
var roster []*db.PlayerWithPlayStatus
|
||||
for _, p := range players {
|
||||
if p.IsManager {
|
||||
managers = append(managers, p)
|
||||
} else {
|
||||
roster = append(roster, p)
|
||||
}
|
||||
}
|
||||
// Sort roster alphabetically by display name
|
||||
sort.Slice(roster, func(i, j int) bool {
|
||||
return roster[i].Player.DisplayName() < roster[j].Player.DisplayName()
|
||||
})
|
||||
}}
|
||||
<div>
|
||||
<!-- Team Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
if team.Color != "" {
|
||||
<span
|
||||
class="w-3.5 h-3.5 rounded-full shrink-0 border border-surface1"
|
||||
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
||||
></span>
|
||||
}
|
||||
<h3 class="text-md font-bold">
|
||||
@links.TeamNameLinkInSeason(team, season, league)
|
||||
</h3>
|
||||
</div>
|
||||
<span class="text-xs text-subtext0">
|
||||
{ fmt.Sprint(len(players)) } players
|
||||
</span>
|
||||
</div>
|
||||
if len(players) == 0 {
|
||||
<p class="text-subtext1 text-sm text-center py-4">No players on roster.</p>
|
||||
} else {
|
||||
<div class="space-y-1">
|
||||
<!-- Manager(s) -->
|
||||
for _, p := range managers {
|
||||
<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface0 border border-surface1">
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium shrink-0">
|
||||
★
|
||||
</span>
|
||||
<span class="text-sm font-medium">
|
||||
@links.PlayerLink(p.Player)
|
||||
</span>
|
||||
if p.IsFreeAgent {
|
||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||
FA
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Regular Players -->
|
||||
for _, p := range roster {
|
||||
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface0 transition">
|
||||
<span class="text-sm">
|
||||
@links.PlayerLink(p.Player)
|
||||
</span>
|
||||
if p.IsFreeAgent {
|
||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||
FA
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
Reference in New Issue
Block a user