fixed fixture page to use htmx tab pattern

This commit is contained in:
2026-03-07 12:34:49 +11:00
parent 8b414ff7f0
commit 9f6a2303a0
3 changed files with 382 additions and 88 deletions

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -20,8 +21,7 @@ import (
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
// FixtureDetailPage renders the fixture detail page with scheduling UI, history, // FixtureDetailPage redirects to the default tab (overview)
// result display, and team rosters
func FixtureDetailPage( func FixtureDetailPage(
s *hws.Server, s *hws.Server,
conn *db.DB, conn *db.DB,
@@ -33,9 +33,230 @@ func FixtureDetailPage(
return return
} }
activeTab := r.URL.Query().Get("tab") http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/overview", fixtureID), http.StatusSeeOther)
if activeTab == "" { })
activeTab = "overview" }
// FixtureDetailOverviewPage renders the overview tab of the fixture detail page
func FixtureDetailOverviewPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
var currentSchedule *db.FixtureSchedule
var canSchedule bool
var userTeamID int
var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus
var nominatedFreeAgents []*db.FixtureFreeAgent
var availableFreeAgents []*db.SeasonLeagueFreeAgent
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
currentSchedule, err = db.GetCurrentFixtureSchedule(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetCurrentFixtureSchedule")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err = fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
nominatedFreeAgents, err = db.GetNominatedFreeAgents(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
}
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
if canSchedule || canManage {
availableFreeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
}
}
return true, nil
}); !ok {
return
}
if r.Method == "GET" {
renderSafely(seasonsview.FixtureDetailOverviewPage(
fixture, currentSchedule, canSchedule, userTeamID,
result, rosters, nominatedFreeAgents, availableFreeAgents,
), s, r, w)
} else {
renderSafely(seasonsview.FixtureDetailOverviewContent(
fixture, currentSchedule, canSchedule, userTeamID,
result, rosters, nominatedFreeAgents, availableFreeAgents,
), s, r, w)
}
})
}
// FixtureDetailPreviewPage renders the match preview tab of the fixture detail page
func FixtureDetailPreviewPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus
var previewData *db.MatchPreviewData
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
previewData, err = db.ComputeMatchPreview(ctx, tx, fixture)
if err != nil {
return false, errors.Wrap(err, "db.ComputeMatchPreview")
}
return true, nil
}); !ok {
return
}
// If finalized, redirect to analysis instead
if result != nil && result.Finalized {
if r.Method == "GET" {
http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/analysis", fixtureID), http.StatusSeeOther)
} else {
respond.HXRedirect(w, "/fixtures/%d/analysis", fixtureID)
}
return
}
if r.Method == "GET" {
renderSafely(seasonsview.FixtureDetailPreviewPage(
fixture, result, rosters, previewData,
), s, r, w)
} else {
renderSafely(seasonsview.FixtureDetailPreviewContent(
fixture, rosters, previewData,
), s, r, w)
}
})
}
// FixtureDetailAnalysisPage renders the match analysis tab of the fixture detail page
func FixtureDetailAnalysisPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus
var previewData *db.MatchPreviewData
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
previewData, err = db.ComputeMatchPreview(ctx, tx, fixture)
if err != nil {
return false, errors.Wrap(err, "db.ComputeMatchPreview")
}
return true, nil
}); !ok {
return
}
// If not finalized, redirect to preview instead
if result == nil || !result.Finalized {
if r.Method == "GET" {
http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/preview", fixtureID), http.StatusSeeOther)
} else {
respond.HXRedirect(w, "/fixtures/%d/preview", fixtureID)
}
return
}
if r.Method == "GET" {
renderSafely(seasonsview.FixtureDetailAnalysisPage(
fixture, result, rosters, previewData,
), s, r, w)
} else {
renderSafely(seasonsview.FixtureDetailAnalysisContent(
fixture, result, rosters, previewData,
), s, r, w)
}
})
}
// FixtureDetailSchedulePage renders the schedule tab of the fixture detail page
func FixtureDetailSchedulePage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
} }
var fixture *db.Fixture var fixture *db.Fixture
@@ -44,10 +265,6 @@ func FixtureDetailPage(
var canSchedule bool var canSchedule bool
var userTeamID int var userTeamID int
var result *db.FixtureResult var result *db.FixtureResult
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) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
@@ -72,48 +289,34 @@ func FixtureDetailPage(
if err != nil { if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule") return false, errors.Wrap(err, "fixture.CanSchedule")
} }
// Fetch fixture result if it exists
result, err = db.GetFixtureResult(ctx, tx, fixtureID) result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult") return false, errors.Wrap(err, "db.GetFixtureResult")
} }
// Fetch team rosters with play status
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
// Fetch free agent nominations for this fixture
nominatedFreeAgents, err = db.GetNominatedFreeAgents(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
}
// Fetch available free agents for nomination (if user can schedule or manage fixtures)
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
if canSchedule || canManage {
availableFreeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
if err != nil {
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 return true, nil
}); !ok { }); !ok {
return return
} }
renderSafely(seasonsview.FixtureDetailPage( // If finalized, redirect to overview (scheduling tab is hidden)
fixture, currentSchedule, history, canSchedule, userTeamID, if result != nil && result.Finalized {
result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents, if r.Method == "GET" {
previewData, http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/overview", fixtureID), http.StatusSeeOther)
), s, r, w) } else {
respond.HXRedirect(w, "/fixtures/%d/overview", fixtureID)
}
return
}
if r.Method == "GET" {
renderSafely(seasonsview.FixtureDetailSchedulePage(
fixture, currentSchedule, history, canSchedule, userTeamID,
), s, r, w)
} else {
renderSafely(seasonsview.FixtureDetailScheduleContent(
fixture, currentSchedule, history, canSchedule, userTeamID,
), s, r, w)
}
}) })
} }

View File

@@ -219,6 +219,27 @@ func addRoutes(
Method: hws.MethodDELETE, Method: hws.MethodDELETE,
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)), Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)),
}, },
// Fixture detail tab routes
{
Path: "/fixtures/{fixture_id}/overview",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.FixtureDetailOverviewPage(s, conn),
},
{
Path: "/fixtures/{fixture_id}/preview",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.FixtureDetailPreviewPage(s, conn),
},
{
Path: "/fixtures/{fixture_id}/analysis",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.FixtureDetailAnalysisPage(s, conn),
},
{
Path: "/fixtures/{fixture_id}/scheduling",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.FixtureDetailSchedulePage(s, conn),
},
// Fixture scheduling routes // Fixture scheduling routes
{ {
Path: "/fixtures/{fixture_id}/schedule", Path: "/fixtures/{fixture_id}/schedule",

View File

@@ -9,39 +9,12 @@ import "fmt"
import "sort" import "sort"
import "strings" import "strings"
templ FixtureDetailPage( // FixtureDetailLayout renders the fixture detail page layout with header and
fixture *db.Fixture, // tab navigation. Tab content is rendered as children.
currentSchedule *db.FixtureSchedule, templ FixtureDetailLayout(activeTab string, fixture *db.Fixture, result *db.FixtureResult) {
history []*db.FixtureSchedule,
canSchedule bool,
userTeamID int,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
activeTab string,
nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent,
previewData *db.MatchPreviewData,
) {
{{ {{
permCache := contexts.Permissions(ctx)
canManage := permCache.HasPermission(permissions.FixturesManage)
backURL := fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", fixture.Season.ShortName, fixture.League.ShortName) backURL := fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", fixture.Season.ShortName, fixture.League.ShortName)
isFinalized := result != nil && result.Finalized isFinalized := result != nil && result.Finalized
if activeTab == "" {
activeTab = "overview"
}
// Force overview if schedule tab is hidden (result finalized)
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)) { @baseview.Layout(fmt.Sprintf("%s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
<div class="max-w-screen-lg mx-auto px-4 py-8"> <div class="max-w-screen-lg mx-auto px-4 py-8">
@@ -81,29 +54,24 @@ templ FixtureDetailPage(
</div> </div>
</div> </div>
<!-- Tab Navigation --> <!-- Tab Navigation -->
<nav class="bg-surface0 border-b border-surface1"> <nav class="bg-surface0 border-b border-surface1" data-tab-nav="fixture-detail-content">
<ul class="flex flex-wrap"> <ul class="flex flex-wrap">
@fixtureTabItem("overview", "Overview", activeTab, fixture) @fixtureTabItem("overview", "Overview", activeTab, fixture)
if isFinalized { if isFinalized {
@fixtureTabItem("analysis", "Match Analysis", activeTab, fixture) @fixtureTabItem("analysis", "Match Analysis", activeTab, fixture)
} else { } else {
@fixtureTabItem("preview", "Match Preview", activeTab, fixture) @fixtureTabItem("preview", "Match Preview", activeTab, fixture)
@fixtureTabItem("schedule", "Schedule", activeTab, fixture) @fixtureTabItem("scheduling", "Schedule", activeTab, fixture)
} }
</ul> </ul>
</nav> </nav>
</div> </div>
<!-- Tab Content --> <!-- Content Area -->
if activeTab == "overview" { <main id="fixture-detail-content">
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents) { children... }
} else if activeTab == "preview" && previewData != nil { </main>
@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)
}
</div> </div>
<script src="/static/js/tabs.js" defer></script>
} }
} }
@@ -113,14 +81,15 @@ templ fixtureTabItem(section string, label string, activeTab string, fixture *db
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2" baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
activeClasses := "border-blue text-blue font-semibold" activeClasses := "border-blue text-blue font-semibold"
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2" inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
url := fmt.Sprintf("/fixtures/%d", fixture.ID) url := fmt.Sprintf("/fixtures/%d/%s", fixture.ID, section)
if section != "overview" {
url = fmt.Sprintf("/fixtures/%d?tab=%s", fixture.ID, section)
}
}} }}
<li class="inline-block"> <li class="inline-block">
<a <a
href={ templ.SafeURL(url) } href={ templ.SafeURL(url) }
hx-post={ url }
hx-target="#fixture-detail-content"
hx-swap="innerHTML"
hx-push-url={ url }
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) } class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
> >
{ label } { label }
@@ -128,6 +97,107 @@ templ fixtureTabItem(section string, label string, activeTab string, fixture *db
</li> </li>
} }
// ==================== Full page wrappers (for GET requests / direct navigation) ====================
templ FixtureDetailOverviewPage(
fixture *db.Fixture,
currentSchedule *db.FixtureSchedule,
canSchedule bool,
userTeamID int,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent,
) {
@FixtureDetailLayout("overview", fixture, result) {
@FixtureDetailOverviewContent(fixture, currentSchedule, canSchedule, userTeamID, result, rosters, nominatedFreeAgents, availableFreeAgents)
}
}
templ FixtureDetailPreviewPage(
fixture *db.Fixture,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@FixtureDetailLayout("preview", fixture, result) {
@FixtureDetailPreviewContent(fixture, rosters, previewData)
}
}
templ FixtureDetailAnalysisPage(
fixture *db.Fixture,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@FixtureDetailLayout("analysis", fixture, result) {
@FixtureDetailAnalysisContent(fixture, result, rosters, previewData)
}
}
templ FixtureDetailSchedulePage(
fixture *db.Fixture,
currentSchedule *db.FixtureSchedule,
history []*db.FixtureSchedule,
canSchedule bool,
userTeamID int,
) {
@FixtureDetailLayout("scheduling", fixture, nil) {
@FixtureDetailScheduleContent(fixture, currentSchedule, history, canSchedule, userTeamID)
}
}
// ==================== Tab content components (for POST requests / HTMX swaps) ====================
templ FixtureDetailOverviewContent(
fixture *db.Fixture,
currentSchedule *db.FixtureSchedule,
canSchedule bool,
userTeamID int,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent,
) {
{{
permCache := contexts.Permissions(ctx)
canManage := permCache.HasPermission(permissions.FixturesManage)
}}
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents)
}
templ FixtureDetailPreviewContent(
fixture *db.Fixture,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@fixtureMatchPreviewTab(fixture, rosters, previewData)
}
templ FixtureDetailAnalysisContent(
fixture *db.Fixture,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@fixtureMatchAnalysisTab(fixture, result, rosters, previewData)
}
templ FixtureDetailScheduleContent(
fixture *db.Fixture,
currentSchedule *db.FixtureSchedule,
history []*db.FixtureSchedule,
canSchedule bool,
userTeamID int,
) {
{{
permCache := contexts.Permissions(ctx)
canManage := permCache.HasPermission(permissions.FixturesManage)
}}
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
}
// ==================== Overview Tab ==================== // ==================== Overview Tab ====================
templ fixtureOverviewTab( templ fixtureOverviewTab(
fixture *db.Fixture, fixture *db.Fixture,