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 {
-
-
- @fixtureTabItem("overview", "Overview", activeTab, fixture)
+
+
+
+ @fixtureTabItem("overview", "Overview", activeTab, fixture)
+ if isFinalized {
+ @fixtureTabItem("analysis", "Match Analysis", activeTab, fixture)
+ } else {
+ @fixtureTabItem("preview", "Match Preview", activeTab, fixture)
@fixtureTabItem("schedule", "Schedule", activeTab, fixture)
-
-
- }
+ }
+
+
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
+
+ }
+
+ }
+
+ }
+
+}