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-3xl--line-height: calc(2.25 / 1.875);
|
||||||
--text-4xl: 2.25rem;
|
--text-4xl: 2.25rem;
|
||||||
--text-4xl--line-height: calc(2.5 / 2.25);
|
--text-4xl--line-height: calc(2.5 / 2.25);
|
||||||
|
--text-5xl: 3rem;
|
||||||
|
--text-5xl--line-height: 1;
|
||||||
--text-6xl: 3.75rem;
|
--text-6xl: 3.75rem;
|
||||||
--text-6xl--line-height: 1;
|
--text-6xl--line-height: 1;
|
||||||
--text-9xl: 8rem;
|
--text-9xl: 8rem;
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
--tracking-tight: -0.025em;
|
--tracking-tight: -0.025em;
|
||||||
--tracking-wider: 0.05em;
|
--tracking-wider: 0.05em;
|
||||||
--leading-relaxed: 1.625;
|
--leading-relaxed: 1.625;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
--radius-lg: 0.5rem;
|
--radius-lg: 0.5rem;
|
||||||
--radius-xl: 0.75rem;
|
--radius-xl: 0.75rem;
|
||||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||||
@@ -450,6 +453,9 @@
|
|||||||
.h-3 {
|
.h-3 {
|
||||||
height: calc(var(--spacing) * 3);
|
height: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
|
.h-3\.5 {
|
||||||
|
height: calc(var(--spacing) * 3.5);
|
||||||
|
}
|
||||||
.h-4 {
|
.h-4 {
|
||||||
height: calc(var(--spacing) * 4);
|
height: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -459,9 +465,15 @@
|
|||||||
.h-6 {
|
.h-6 {
|
||||||
height: calc(var(--spacing) * 6);
|
height: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
.h-9 {
|
||||||
|
height: calc(var(--spacing) * 9);
|
||||||
|
}
|
||||||
.h-12 {
|
.h-12 {
|
||||||
height: calc(var(--spacing) * 12);
|
height: calc(var(--spacing) * 12);
|
||||||
}
|
}
|
||||||
|
.h-14 {
|
||||||
|
height: calc(var(--spacing) * 14);
|
||||||
|
}
|
||||||
.h-16 {
|
.h-16 {
|
||||||
height: calc(var(--spacing) * 16);
|
height: calc(var(--spacing) * 16);
|
||||||
}
|
}
|
||||||
@@ -510,6 +522,9 @@
|
|||||||
.w-3 {
|
.w-3 {
|
||||||
width: calc(var(--spacing) * 3);
|
width: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
|
.w-3\.5 {
|
||||||
|
width: calc(var(--spacing) * 3.5);
|
||||||
|
}
|
||||||
.w-4 {
|
.w-4 {
|
||||||
width: calc(var(--spacing) * 4);
|
width: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -519,18 +534,30 @@
|
|||||||
.w-6 {
|
.w-6 {
|
||||||
width: calc(var(--spacing) * 6);
|
width: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
.w-8 {
|
||||||
|
width: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
|
.w-9 {
|
||||||
|
width: calc(var(--spacing) * 9);
|
||||||
|
}
|
||||||
.w-10 {
|
.w-10 {
|
||||||
width: calc(var(--spacing) * 10);
|
width: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
.w-12 {
|
.w-12 {
|
||||||
width: calc(var(--spacing) * 12);
|
width: calc(var(--spacing) * 12);
|
||||||
}
|
}
|
||||||
|
.w-14 {
|
||||||
|
width: calc(var(--spacing) * 14);
|
||||||
|
}
|
||||||
.w-20 {
|
.w-20 {
|
||||||
width: calc(var(--spacing) * 20);
|
width: calc(var(--spacing) * 20);
|
||||||
}
|
}
|
||||||
.w-26 {
|
.w-26 {
|
||||||
width: calc(var(--spacing) * 26);
|
width: calc(var(--spacing) * 26);
|
||||||
}
|
}
|
||||||
|
.w-28 {
|
||||||
|
width: calc(var(--spacing) * 28);
|
||||||
|
}
|
||||||
.w-48 {
|
.w-48 {
|
||||||
width: calc(var(--spacing) * 48);
|
width: calc(var(--spacing) * 48);
|
||||||
}
|
}
|
||||||
@@ -636,6 +663,9 @@
|
|||||||
.animate-spin {
|
.animate-spin {
|
||||||
animation: var(--animate-spin);
|
animation: var(--animate-spin);
|
||||||
}
|
}
|
||||||
|
.cursor-default {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
.cursor-grab {
|
.cursor-grab {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
@@ -729,6 +759,13 @@
|
|||||||
.gap-8 {
|
.gap-8 {
|
||||||
gap: calc(var(--spacing) * 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 {
|
.space-y-0\.5 {
|
||||||
:where(& > :not(:last-child)) {
|
:where(& > :not(:last-child)) {
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
@@ -743,6 +780,13 @@
|
|||||||
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
|
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 {
|
.space-y-2 {
|
||||||
:where(& > :not(:last-child)) {
|
:where(& > :not(:last-child)) {
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
@@ -867,6 +911,9 @@
|
|||||||
.rounded-lg {
|
.rounded-lg {
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
.rounded-md {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
.rounded-xl {
|
.rounded-xl {
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
}
|
}
|
||||||
@@ -1012,6 +1059,12 @@
|
|||||||
.bg-green {
|
.bg-green {
|
||||||
background-color: var(--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 {
|
.bg-green\/20 {
|
||||||
background-color: var(--green);
|
background-color: var(--green);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -1030,6 +1083,18 @@
|
|||||||
.bg-mauve {
|
.bg-mauve {
|
||||||
background-color: var(--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 {
|
.bg-peach {
|
||||||
background-color: var(--peach);
|
background-color: var(--peach);
|
||||||
}
|
}
|
||||||
@@ -1039,6 +1104,12 @@
|
|||||||
background-color: color-mix(in oklab, var(--peach) 5%, transparent);
|
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 {
|
.bg-peach\/20 {
|
||||||
background-color: var(--peach);
|
background-color: var(--peach);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -1060,12 +1131,24 @@
|
|||||||
background-color: color-mix(in oklab, var(--red) 10%, transparent);
|
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 {
|
.bg-red\/20 {
|
||||||
background-color: var(--red);
|
background-color: var(--red);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
background-color: color-mix(in oklab, var(--red) 20%, transparent);
|
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 {
|
.bg-sapphire {
|
||||||
background-color: var(--sapphire);
|
background-color: var(--sapphire);
|
||||||
}
|
}
|
||||||
@@ -1123,6 +1206,9 @@
|
|||||||
.p-8 {
|
.p-8 {
|
||||||
padding: calc(var(--spacing) * 8);
|
padding: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
|
.px-1 {
|
||||||
|
padding-inline: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.px-1\.5 {
|
.px-1\.5 {
|
||||||
padding-inline: calc(var(--spacing) * 1.5);
|
padding-inline: calc(var(--spacing) * 1.5);
|
||||||
}
|
}
|
||||||
@@ -1156,6 +1242,9 @@
|
|||||||
.py-2 {
|
.py-2 {
|
||||||
padding-block: calc(var(--spacing) * 2);
|
padding-block: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.py-2\.5 {
|
||||||
|
padding-block: calc(var(--spacing) * 2.5);
|
||||||
|
}
|
||||||
.py-3 {
|
.py-3 {
|
||||||
padding-block: calc(var(--spacing) * 3);
|
padding-block: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
@@ -1189,6 +1278,9 @@
|
|||||||
.pr-2 {
|
.pr-2 {
|
||||||
padding-right: calc(var(--spacing) * 2);
|
padding-right: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.pr-4 {
|
||||||
|
padding-right: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
.pr-10 {
|
.pr-10 {
|
||||||
padding-right: calc(var(--spacing) * 10);
|
padding-right: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
@@ -1204,6 +1296,9 @@
|
|||||||
.pl-3 {
|
.pl-3 {
|
||||||
padding-left: calc(var(--spacing) * 3);
|
padding-left: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
|
.pl-4 {
|
||||||
|
padding-left: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -1228,6 +1323,10 @@
|
|||||||
font-size: var(--text-4xl);
|
font-size: var(--text-4xl);
|
||||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
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 {
|
.text-9xl {
|
||||||
font-size: var(--text-9xl);
|
font-size: var(--text-9xl);
|
||||||
line-height: var(--tw-leading, var(--text-9xl--line-height));
|
line-height: var(--tw-leading, var(--text-9xl--line-height));
|
||||||
@@ -1544,6 +1643,12 @@
|
|||||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
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\:-translate-y-0\.5 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -2026,6 +2131,11 @@
|
|||||||
width: calc(var(--spacing) * 10);
|
width: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:w-36 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
width: calc(var(--spacing) * 36);
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:w-auto {
|
.sm\:w-auto {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -2098,6 +2208,16 @@
|
|||||||
gap: calc(var(--spacing) * 2);
|
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 {
|
.sm\:p-6 {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
padding: calc(var(--spacing) * 6);
|
padding: calc(var(--spacing) * 6);
|
||||||
@@ -2128,12 +2248,30 @@
|
|||||||
text-align: left;
|
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 {
|
.sm\:text-4xl {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
font-size: var(--text-4xl);
|
font-size: var(--text-4xl);
|
||||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
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 {
|
.md\:col-span-2 {
|
||||||
@media (width >= 48rem) {
|
@media (width >= 48rem) {
|
||||||
grid-column: span 2 / span 2;
|
grid-column: span 2 / span 2;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ func FixtureDetailPage(
|
|||||||
var rosters map[string][]*db.PlayerWithPlayStatus
|
var rosters map[string][]*db.PlayerWithPlayStatus
|
||||||
var nominatedFreeAgents []*db.FixtureFreeAgent
|
var nominatedFreeAgents []*db.FixtureFreeAgent
|
||||||
var availableFreeAgents []*db.SeasonLeagueFreeAgent
|
var availableFreeAgents []*db.SeasonLeagueFreeAgent
|
||||||
|
var previewData *db.MatchPreviewData
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
@@ -94,6 +95,15 @@ func FixtureDetailPage(
|
|||||||
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
|
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
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
@@ -102,6 +112,7 @@ func FixtureDetailPage(
|
|||||||
renderSafely(seasonsview.FixtureDetailPage(
|
renderSafely(seasonsview.FixtureDetailPage(
|
||||||
fixture, currentSchedule, history, canSchedule, userTeamID,
|
fixture, currentSchedule, history, canSchedule, userTeamID,
|
||||||
result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents,
|
result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents,
|
||||||
|
previewData,
|
||||||
), s, r, w)
|
), s, r, w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ templ FixtureDetailPage(
|
|||||||
activeTab string,
|
activeTab string,
|
||||||
nominatedFreeAgents []*db.FixtureFreeAgent,
|
nominatedFreeAgents []*db.FixtureFreeAgent,
|
||||||
availableFreeAgents []*db.SeasonLeagueFreeAgent,
|
availableFreeAgents []*db.SeasonLeagueFreeAgent,
|
||||||
|
previewData *db.MatchPreviewData,
|
||||||
) {
|
) {
|
||||||
{{
|
{{
|
||||||
permCache := contexts.Permissions(ctx)
|
permCache := contexts.Permissions(ctx)
|
||||||
@@ -33,6 +34,14 @@ templ FixtureDetailPage(
|
|||||||
if isFinalized && activeTab == "schedule" {
|
if isFinalized && activeTab == "schedule" {
|
||||||
activeTab = "overview"
|
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)) {
|
@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">
|
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
||||||
@@ -71,19 +80,26 @@ templ FixtureDetailPage(
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tab Navigation (hidden when only one tab) -->
|
<!-- Tab Navigation -->
|
||||||
if !isFinalized {
|
<nav class="bg-surface0 border-b border-surface1">
|
||||||
<nav class="bg-surface0 border-b border-surface1">
|
<ul class="flex flex-wrap">
|
||||||
<ul class="flex flex-wrap">
|
@fixtureTabItem("overview", "Overview", activeTab, fixture)
|
||||||
@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)
|
@fixtureTabItem("schedule", "Schedule", activeTab, fixture)
|
||||||
</ul>
|
}
|
||||||
</nav>
|
</ul>
|
||||||
}
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tab Content -->
|
<!-- Tab Content -->
|
||||||
if activeTab == "overview" {
|
if activeTab == "overview" {
|
||||||
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents)
|
@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" {
|
} else if activeTab == "schedule" {
|
||||||
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
|
@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