added match preview and analysis
This commit is contained in:
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