added match preview and analysis

This commit is contained in:
2026-03-06 22:08:41 +11:00
parent b957df8d32
commit f5c9e70edf
6 changed files with 1473 additions and 8 deletions

View File

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

View 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">
&#9733;
</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>
}

View 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">
&#9733;
</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>
}