added match preview and analysis

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

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