From f5c9e70edfb2e280e310260b26e059068c228652 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 6 Mar 2026 22:08:41 +1100 Subject: [PATCH] added match preview and analysis --- internal/db/match_preview.go | 254 ++++++++ internal/embedfs/web/css/output.css | 138 ++++ internal/handlers/fixture_detail.go | 11 + .../view/seasonsview/fixture_detail.templ | 32 +- .../seasonsview/fixture_match_analysis.templ | 611 ++++++++++++++++++ .../seasonsview/fixture_match_preview.templ | 435 +++++++++++++ 6 files changed, 1473 insertions(+), 8 deletions(-) create mode 100644 internal/db/match_preview.go create mode 100644 internal/view/seasonsview/fixture_match_analysis.templ create mode 100644 internal/view/seasonsview/fixture_match_preview.templ diff --git a/internal/db/match_preview.go b/internal/db/match_preview.go new file mode 100644 index 0000000..beb2c5c --- /dev/null +++ b/internal/db/match_preview.go @@ -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 +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index ce19615..b9f0c88 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -35,6 +35,8 @@ --text-3xl--line-height: calc(2.25 / 1.875); --text-4xl: 2.25rem; --text-4xl--line-height: calc(2.5 / 2.25); + --text-5xl: 3rem; + --text-5xl--line-height: 1; --text-6xl: 3.75rem; --text-6xl--line-height: 1; --text-9xl: 8rem; @@ -47,6 +49,7 @@ --tracking-tight: -0.025em; --tracking-wider: 0.05em; --leading-relaxed: 1.625; + --radius-md: 0.375rem; --radius-lg: 0.5rem; --radius-xl: 0.75rem; --ease-in: cubic-bezier(0.4, 0, 1, 1); @@ -450,6 +453,9 @@ .h-3 { height: calc(var(--spacing) * 3); } + .h-3\.5 { + height: calc(var(--spacing) * 3.5); + } .h-4 { height: calc(var(--spacing) * 4); } @@ -459,9 +465,15 @@ .h-6 { height: calc(var(--spacing) * 6); } + .h-9 { + height: calc(var(--spacing) * 9); + } .h-12 { height: calc(var(--spacing) * 12); } + .h-14 { + height: calc(var(--spacing) * 14); + } .h-16 { height: calc(var(--spacing) * 16); } @@ -510,6 +522,9 @@ .w-3 { width: calc(var(--spacing) * 3); } + .w-3\.5 { + width: calc(var(--spacing) * 3.5); + } .w-4 { width: calc(var(--spacing) * 4); } @@ -519,18 +534,30 @@ .w-6 { width: calc(var(--spacing) * 6); } + .w-8 { + width: calc(var(--spacing) * 8); + } + .w-9 { + width: calc(var(--spacing) * 9); + } .w-10 { width: calc(var(--spacing) * 10); } .w-12 { width: calc(var(--spacing) * 12); } + .w-14 { + width: calc(var(--spacing) * 14); + } .w-20 { width: calc(var(--spacing) * 20); } .w-26 { width: calc(var(--spacing) * 26); } + .w-28 { + width: calc(var(--spacing) * 28); + } .w-48 { width: calc(var(--spacing) * 48); } @@ -636,6 +663,9 @@ .animate-spin { animation: var(--animate-spin); } + .cursor-default { + cursor: default; + } .cursor-grab { cursor: grab; } @@ -729,6 +759,13 @@ .gap-8 { gap: calc(var(--spacing) * 8); } + .space-y-0 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-0\.5 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -743,6 +780,13 @@ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } + .space-y-1\.5 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-2 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -867,6 +911,9 @@ .rounded-lg { border-radius: var(--radius-lg); } + .rounded-md { + border-radius: var(--radius-md); + } .rounded-xl { border-radius: var(--radius-xl); } @@ -1012,6 +1059,12 @@ .bg-green { background-color: var(--green); } + .bg-green\/10 { + background-color: var(--green); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--green) 10%, transparent); + } + } .bg-green\/20 { background-color: var(--green); @supports (color: color-mix(in lab, red, red)) { @@ -1030,6 +1083,18 @@ .bg-mauve { background-color: var(--mauve); } + .bg-overlay0\/10 { + background-color: var(--overlay0); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--overlay0) 10%, transparent); + } + } + .bg-overlay0\/20 { + background-color: var(--overlay0); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--overlay0) 20%, transparent); + } + } .bg-peach { background-color: var(--peach); } @@ -1039,6 +1104,12 @@ background-color: color-mix(in oklab, var(--peach) 5%, transparent); } } + .bg-peach\/10 { + background-color: var(--peach); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--peach) 10%, transparent); + } + } .bg-peach\/20 { background-color: var(--peach); @supports (color: color-mix(in lab, red, red)) { @@ -1060,12 +1131,24 @@ background-color: color-mix(in oklab, var(--red) 10%, transparent); } } + .bg-red\/15 { + background-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--red) 15%, transparent); + } + } .bg-red\/20 { background-color: var(--red); @supports (color: color-mix(in lab, red, red)) { background-color: color-mix(in oklab, var(--red) 20%, transparent); } } + .bg-red\/30 { + background-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--red) 30%, transparent); + } + } .bg-sapphire { background-color: var(--sapphire); } @@ -1123,6 +1206,9 @@ .p-8 { padding: calc(var(--spacing) * 8); } + .px-1 { + padding-inline: calc(var(--spacing) * 1); + } .px-1\.5 { padding-inline: calc(var(--spacing) * 1.5); } @@ -1156,6 +1242,9 @@ .py-2 { padding-block: calc(var(--spacing) * 2); } + .py-2\.5 { + padding-block: calc(var(--spacing) * 2.5); + } .py-3 { padding-block: calc(var(--spacing) * 3); } @@ -1189,6 +1278,9 @@ .pr-2 { padding-right: calc(var(--spacing) * 2); } + .pr-4 { + padding-right: calc(var(--spacing) * 4); + } .pr-10 { padding-right: calc(var(--spacing) * 10); } @@ -1204,6 +1296,9 @@ .pl-3 { padding-left: calc(var(--spacing) * 3); } + .pl-4 { + padding-left: calc(var(--spacing) * 4); + } .text-center { text-align: center; } @@ -1228,6 +1323,10 @@ font-size: var(--text-4xl); line-height: var(--tw-leading, var(--text-4xl--line-height)); } + .text-5xl { + font-size: var(--text-5xl); + line-height: var(--tw-leading, var(--text-5xl--line-height)); + } .text-9xl { font-size: var(--text-9xl); line-height: var(--tw-leading, var(--text-9xl--line-height)); @@ -1544,6 +1643,12 @@ transition-duration: var(--tw-duration, var(--default-transition-duration)); } } + .last\:border-b-0 { + &:last-child { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 0px; + } + } .hover\:-translate-y-0\.5 { &:hover { @media (hover: hover) { @@ -2026,6 +2131,11 @@ width: calc(var(--spacing) * 10); } } + .sm\:w-36 { + @media (width >= 40rem) { + width: calc(var(--spacing) * 36); + } + } .sm\:w-auto { @media (width >= 40rem) { width: auto; @@ -2098,6 +2208,16 @@ gap: calc(var(--spacing) * 2); } } + .sm\:gap-8 { + @media (width >= 40rem) { + gap: calc(var(--spacing) * 8); + } + } + .sm\:gap-10 { + @media (width >= 40rem) { + gap: calc(var(--spacing) * 10); + } + } .sm\:p-6 { @media (width >= 40rem) { padding: calc(var(--spacing) * 6); @@ -2128,12 +2248,30 @@ text-align: left; } } + .sm\:text-2xl { + @media (width >= 40rem) { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + } .sm\:text-4xl { @media (width >= 40rem) { font-size: var(--text-4xl); line-height: var(--tw-leading, var(--text-4xl--line-height)); } } + .sm\:text-6xl { + @media (width >= 40rem) { + font-size: var(--text-6xl); + line-height: var(--tw-leading, var(--text-6xl--line-height)); + } + } + .sm\:text-xl { + @media (width >= 40rem) { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + } .md\:col-span-2 { @media (width >= 48rem) { grid-column: span 2 / span 2; diff --git a/internal/handlers/fixture_detail.go b/internal/handlers/fixture_detail.go index e662696..c6398d9 100644 --- a/internal/handlers/fixture_detail.go +++ b/internal/handlers/fixture_detail.go @@ -47,6 +47,7 @@ func FixtureDetailPage( var rosters map[string][]*db.PlayerWithPlayStatus var nominatedFreeAgents []*db.FixtureFreeAgent var availableFreeAgents []*db.SeasonLeagueFreeAgent + var previewData *db.MatchPreviewData if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error @@ -94,6 +95,15 @@ func FixtureDetailPage( return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague") } } + + // Fetch match preview data for preview and analysis tabs + if activeTab == "preview" || activeTab == "analysis" { + previewData, err = db.ComputeMatchPreview(ctx, tx, fixture) + if err != nil { + return false, errors.Wrap(err, "db.ComputeMatchPreview") + } + } + return true, nil }); !ok { return @@ -102,6 +112,7 @@ func FixtureDetailPage( renderSafely(seasonsview.FixtureDetailPage( fixture, currentSchedule, history, canSchedule, userTeamID, result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents, + previewData, ), s, r, w) }) } diff --git a/internal/view/seasonsview/fixture_detail.templ b/internal/view/seasonsview/fixture_detail.templ index 26e8b77..4918db7 100644 --- a/internal/view/seasonsview/fixture_detail.templ +++ b/internal/view/seasonsview/fixture_detail.templ @@ -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)) {
@@ -71,19 +80,26 @@ templ FixtureDetailPage(
- - if !isFinalized { - 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) } diff --git a/internal/view/seasonsview/fixture_match_analysis.templ b/internal/view/seasonsview/fixture_match_analysis.templ new file mode 100644 index 0000000..854256c --- /dev/null +++ b/internal/view/seasonsview/fixture_match_analysis.templ @@ -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, +) { +
+ + @analysisScoreHeader(fixture, result) + + + @analysisTeamStatsComparison(fixture, rosters) + + + @analysisTopPerformers(fixture, rosters) + + + if preview != nil { + @analysisStandingsContext(fixture, preview) + } +
+} + +// 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 + }} +
+
+

Final Score

+
+
+ if isForfeit { + @analysisForfeitDisplay(fixture, result) + } else { +
+ +
+ if fixture.HomeTeam.Color != "" { +
+ } +

+ @links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League) +

+ + { fmt.Sprint(result.HomeScore) } + + if homeWon { + Winner + } +
+ +
+ + if isOT { + OT + } +
+ +
+ if fixture.AwayTeam.Color != "" { +
+ } +

+ @links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League) +

+ + { fmt.Sprint(result.AwayScore) } + + if awayWon { + Winner + } +
+
+ } +
+
+} + +// 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 + } + } + }} +
+ if isMutualForfeit { + MUTUAL FORFEIT +

Both teams receive an overtime loss

+ } else if isOutrightForfeit { + FORFEIT +

+ { forfeitTeamName } forfeited — { winnerTeamName } wins +

+ } + if result.ForfeitReason != nil && *result.ForfeitReason != "" { +
+

Reason

+

{ *result.ForfeitReason }

+
+ } +
+} + +// 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"]) + }} +
+
+

Team Statistics

+
+
+ +
+
+
+ if fixture.HomeTeam.Color != "" { + + } + { fixture.HomeTeam.ShortName } +
+
+
+
+
+ { fixture.AwayTeam.ShortName } + if fixture.AwayTeam.Color != "" { + + } +
+
+
+ +
+ @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, + ) + + {{ + 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, + ) +
+
+
+} + +// 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 { +
+
+

Top Performers

+
+
+
+ +
+
+ if fixture.HomeTeam.Color != "" { + + } +

{ fixture.HomeTeam.Name }

+
+
+ for i, p := range homeTop { + @topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1) + } +
+
+ +
+
+ if fixture.AwayTeam.Color != "" { + + } +

{ fixture.AwayTeam.Name }

+
+
+ for i, p := range awayTop { + @topPerformerCard(p.Player, p.Stats, p.IsManager, p.IsFreeAgent, i+1) + } +
+
+
+
+
+ } +} + +// 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] + }} +
+ { rankLabel } +
+
+ + @links.PlayerLink(player) + + if isManager { + + ★ + + } + if isFreeAgent { + + FA + + } +
+
+ if stats.Score != nil { + { fmt.Sprint(*stats.Score) } SC + } + if stats.Goals != nil { + { fmt.Sprint(*stats.Goals) } G + } + if stats.Assists != nil { + { fmt.Sprint(*stats.Assists) } A + } + if stats.Saves != nil { + { fmt.Sprint(*stats.Saves) } SV + } + if stats.Shots != nil { + { fmt.Sprint(*stats.Shots) } SH + } +
+
+
+} + +// analysisStandingsContext shows how this result fits into the league standings. +templ analysisStandingsContext(fixture *db.Fixture, preview *db.MatchPreviewData) { +
+
+

League Context

+
+
+ +
+
+
+ if fixture.HomeTeam.Color != "" { + + } + { fixture.HomeTeam.ShortName } +
+
+
+
+
+ { fixture.AwayTeam.ShortName } + if fixture.AwayTeam.Color != "" { + + } +
+
+
+
+ {{ + 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, + ) + + if len(preview.HomeRecentGames) > 0 || len(preview.AwayRecentGames) > 0 { +
+
+
+ for _, g := range preview.HomeRecentGames { + @gameOutcomeIcon(g) + } +
+
+
+ Form +
+
+
+ for _, g := range preview.AwayRecentGames { + @gameOutcomeIcon(g) + } +
+
+
+ } +
+
+
+} diff --git a/internal/view/seasonsview/fixture_match_preview.templ b/internal/view/seasonsview/fixture_match_preview.templ new file mode 100644 index 0000000..7a7e91d --- /dev/null +++ b/internal/view/seasonsview/fixture_match_preview.templ @@ -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, +) { +
+ + @matchPreviewHeader(fixture, preview) + + + @matchPreviewFormGuide(fixture, preview) + + + @matchPreviewRosters(fixture, rosters) +
+} + +// matchPreviewHeader renders the broadcast-style team comparison with standings. +templ matchPreviewHeader(fixture *db.Fixture, preview *db.MatchPreviewData) { +
+
+

Team Comparison

+
+
+ +
+ +
+ if fixture.HomeTeam.Color != "" { +
+ } +

+ @links.TeamNameLinkInSeason(fixture.HomeTeam, fixture.Season, fixture.League) +

+ { fixture.HomeTeam.ShortName } +
+ +
+ VS +
+ +
+ if fixture.AwayTeam.Color != "" { +
+ } +

+ @links.TeamNameLinkInSeason(fixture.AwayTeam, fixture.Season, fixture.League) +

+ { fixture.AwayTeam.ShortName } +
+
+ + {{ + 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.Sprint(preview.HomeRecord.Played), + "Played", + fmt.Sprint(preview.AwayRecord.Played), + false, + false, + ) + + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Wins), + "Wins", + fmt.Sprint(preview.AwayRecord.Wins), + preview.HomeRecord.Wins > preview.AwayRecord.Wins, + preview.AwayRecord.Wins > preview.HomeRecord.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, + ) + + @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, + ) + + @previewStatRow( + fmt.Sprint(preview.HomeRecord.Losses), + "Losses", + fmt.Sprint(preview.AwayRecord.Losses), + preview.HomeRecord.Losses < preview.AwayRecord.Losses, + preview.AwayRecord.Losses < preview.HomeRecord.Losses, + ) + + @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, + ) + + @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, + ) + + {{ + 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, + ) +
+
+
+} + +// 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) { +
+ +
+ + { homeValue } + +
+ +
+ { label } +
+ +
+ + { awayValue } + +
+
+} + +// matchPreviewFormGuide renders the recent form section with last 5 game outcome icons. +templ matchPreviewFormGuide(fixture *db.Fixture, preview *db.MatchPreviewData) { +
+
+

Recent Form

+
+
+
+ +
+
+ if fixture.HomeTeam.Color != "" { + + } +

{ fixture.HomeTeam.Name }

+
+ if len(preview.HomeRecentGames) == 0 { +

No recent matches played

+ } else { + +
+ for _, g := range preview.HomeRecentGames { + @gameOutcomeIcon(g) + } +
+ +
+ for i := len(preview.HomeRecentGames) - 1; i >= 0; i-- { + @recentGameRow(preview.HomeRecentGames[i]) + } +
+ } +
+ +
+
+ if fixture.AwayTeam.Color != "" { + + } +

{ fixture.AwayTeam.Name }

+
+ if len(preview.AwayRecentGames) == 0 { +

No recent matches played

+ } else { + +
+ for _, g := range preview.AwayRecentGames { + @gameOutcomeIcon(g) + } +
+ +
+ for i := len(preview.AwayRecentGames) - 1; i >= 0; i-- { + @recentGameRow(preview.AwayRecentGames[i]) + } +
+ } +
+
+
+
+} + +// 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) + } + } + }} + + { style.label } + +} + +// 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 + } + }} +
+ { style.fullLabel } + vs { opponentName } + if outcome.IsForfeit { + Forfeit + } else if outcome.Score != "" { + { outcome.Score } + } +
+} + +// 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"] + }} +
+
+

Team Rosters

+
+
+
+ + @previewRosterColumn(fixture.HomeTeam, homePlayers, fixture.Season, fixture.League) + + @previewRosterColumn(fixture.AwayTeam, awayPlayers, fixture.Season, fixture.League) +
+
+
+} + +// 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() + }) + }} +
+ +
+
+ if team.Color != "" { + + } +

+ @links.TeamNameLinkInSeason(team, season, league) +

+
+ + { fmt.Sprint(len(players)) } players + +
+ if len(players) == 0 { +

No players on roster.

+ } else { +
+ + for _, p := range managers { +
+ + ★ + + + @links.PlayerLink(p.Player) + + if p.IsFreeAgent { + + FA + + } +
+ } + + for _, p := range roster { +
+ + @links.PlayerLink(p.Player) + + if p.IsFreeAgent { + + FA + + } +
+ } +
+ } +
+}