added log file uploading and match results
This commit is contained in:
@@ -26,6 +26,7 @@ templ Layout(title string) {
|
||||
<script src="/static/vendored/htmx@2.0.8.min.js"></script>
|
||||
<script src="/static/vendored/htmx-ext-ws.min.js"></script>
|
||||
<script src="/static/vendored/alpinejs@3.15.4.min.js" defer></script>
|
||||
<script src="/static/js/localtime.js" defer></script>
|
||||
if devInfo.HTMXLog {
|
||||
<script>
|
||||
htmx.logAll();
|
||||
|
||||
@@ -5,18 +5,8 @@ import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||
import "fmt"
|
||||
import "time"
|
||||
|
||||
func formatScheduleTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "No time set"
|
||||
}
|
||||
return t.Format("Mon 2 Jan 2006 at 3:04 PM")
|
||||
}
|
||||
|
||||
func formatHistoryTime(unix int64) string {
|
||||
return time.Unix(unix, 0).Format("2 Jan 2006 15:04")
|
||||
}
|
||||
import "sort"
|
||||
import "strings"
|
||||
|
||||
templ FixtureDetailPage(
|
||||
fixture *db.Fixture,
|
||||
@@ -24,11 +14,22 @@ templ FixtureDetailPage(
|
||||
history []*db.FixtureSchedule,
|
||||
canSchedule bool,
|
||||
userTeamID int,
|
||||
result *db.FixtureResult,
|
||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||
activeTab string,
|
||||
) {
|
||||
{{
|
||||
permCache := contexts.Permissions(ctx)
|
||||
canManage := permCache.HasPermission(permissions.FixturesManage)
|
||||
backURL := fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", fixture.Season.ShortName, fixture.League.ShortName)
|
||||
isFinalized := result != nil && result.Finalized
|
||||
if activeTab == "" {
|
||||
activeTab = "overview"
|
||||
}
|
||||
// Force overview if schedule tab is hidden (result finalized)
|
||||
if isFinalized && activeTab == "schedule" {
|
||||
activeTab = "overview"
|
||||
}
|
||||
}}
|
||||
@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">
|
||||
@@ -67,17 +68,436 @@ templ FixtureDetailPage(
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab Navigation (hidden when only one tab) -->
|
||||
if !isFinalized {
|
||||
<nav class="bg-surface0 border-b border-surface1">
|
||||
<ul class="flex flex-wrap">
|
||||
@fixtureTabItem("overview", "Overview", activeTab, fixture)
|
||||
@fixtureTabItem("schedule", "Schedule", activeTab, fixture)
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
</div>
|
||||
<!-- Schedule Status + Actions -->
|
||||
<div class="space-y-6">
|
||||
@fixtureScheduleStatus(fixture, currentSchedule, canSchedule, canManage, userTeamID)
|
||||
@fixtureScheduleActions(fixture, currentSchedule, canSchedule, canManage, userTeamID)
|
||||
@fixtureScheduleHistory(fixture, history)
|
||||
</div>
|
||||
<!-- Tab Content -->
|
||||
if activeTab == "overview" {
|
||||
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage)
|
||||
} else if activeTab == "schedule" {
|
||||
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ fixtureTabItem(section string, label string, activeTab string, fixture *db.Fixture) {
|
||||
{{
|
||||
isActive := section == activeTab
|
||||
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
|
||||
activeClasses := "border-blue text-blue font-semibold"
|
||||
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
||||
url := fmt.Sprintf("/fixtures/%d", fixture.ID)
|
||||
if section != "overview" {
|
||||
url = fmt.Sprintf("/fixtures/%d?tab=%s", fixture.ID, section)
|
||||
}
|
||||
}}
|
||||
<li class="inline-block">
|
||||
<a
|
||||
href={ templ.SafeURL(url) }
|
||||
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||||
>
|
||||
{ label }
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
|
||||
// ==================== Overview Tab ====================
|
||||
templ fixtureOverviewTab(
|
||||
fixture *db.Fixture,
|
||||
currentSchedule *db.FixtureSchedule,
|
||||
result *db.FixtureResult,
|
||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||
canManage bool,
|
||||
) {
|
||||
<div class="space-y-6">
|
||||
<!-- Result + Schedule Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
if result != nil {
|
||||
@fixtureResultDisplay(fixture, result)
|
||||
} else if canManage {
|
||||
@fixtureUploadPrompt(fixture)
|
||||
} else {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||||
<p class="text-subtext1 text-sm">No result has been uploaded for this fixture yet.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
@fixtureScheduleSummary(fixture, currentSchedule, result)
|
||||
</div>
|
||||
</div>
|
||||
<!-- Team Rosters -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result)
|
||||
@fixtureTeamSection(fixture.AwayTeam, rosters["away"], "away", result)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ fixtureScheduleSummary(fixture *db.Fixture, schedule *db.FixtureSchedule, result *db.FixtureResult) {
|
||||
{{
|
||||
isPlayed := result != nil && result.Finalized
|
||||
}}
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden h-full">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Schedule</h2>
|
||||
</div>
|
||||
<div class="p-4 flex flex-col justify-center h-[calc(100%-3rem)]">
|
||||
if schedule == nil {
|
||||
<div class="text-center">
|
||||
<p class="text-subtext1 text-sm">No time scheduled</p>
|
||||
</div>
|
||||
} else if schedule.Status == db.ScheduleStatusAccepted && schedule.ScheduledTime != nil {
|
||||
<div class="text-center space-y-2">
|
||||
if isPlayed {
|
||||
<span class="px-2 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium">
|
||||
Played
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
||||
Confirmed
|
||||
</span>
|
||||
}
|
||||
<p class="text-text font-medium">
|
||||
@localtime(schedule.ScheduledTime, "date")
|
||||
</p>
|
||||
<p class="text-text text-lg font-bold">
|
||||
@localtime(schedule.ScheduledTime, "time")
|
||||
</p>
|
||||
</div>
|
||||
} else if schedule.Status == db.ScheduleStatusPending && schedule.ScheduledTime != nil {
|
||||
<div class="text-center space-y-2">
|
||||
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
Proposed
|
||||
</span>
|
||||
<p class="text-text font-medium">
|
||||
@localtime(schedule.ScheduledTime, "date")
|
||||
</p>
|
||||
<p class="text-text text-lg font-bold">
|
||||
@localtime(schedule.ScheduledTime, "time")
|
||||
</p>
|
||||
<p class="text-subtext1 text-xs">Awaiting confirmation</p>
|
||||
</div>
|
||||
} else if schedule.Status == db.ScheduleStatusCancelled {
|
||||
<div class="text-center space-y-2">
|
||||
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
|
||||
Forfeit
|
||||
</span>
|
||||
if schedule.RescheduleReason != nil {
|
||||
<p class="text-subtext1 text-xs">{ *schedule.RescheduleReason }</p>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="text-center">
|
||||
<p class="text-subtext1 text-sm">No time confirmed</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ fixtureResultDisplay(fixture *db.Fixture, result *db.FixtureResult) {
|
||||
{{
|
||||
isOT := strings.EqualFold(result.EndReason, "Overtime")
|
||||
homeWon := result.Winner == "home"
|
||||
awayWon := result.Winner == "away"
|
||||
}}
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-text">Match Result</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
if result.Finalized {
|
||||
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
||||
Finalized
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
Pending Review
|
||||
</span>
|
||||
}
|
||||
if !result.Finalized && result.TamperingDetected {
|
||||
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
|
||||
Inconsistent Data
|
||||
</span>
|
||||
}
|
||||
if result.Finalized && result.TamperingDetected && result.TamperingReason != nil {
|
||||
<span class="relative group">
|
||||
<span class="text-yellow text-lg cursor-help">⚠</span>
|
||||
<span
|
||||
class="absolute right-0 top-full mt-1 w-72 bg-crust border border-surface1 rounded-lg
|
||||
p-3 text-xs text-subtext0 shadow-lg opacity-0 invisible
|
||||
group-hover:opacity-100 group-hover:visible transition-all z-50"
|
||||
>
|
||||
<span class="text-yellow font-semibold block mb-1">Inconsistent Data</span>
|
||||
{ *result.TamperingReason }
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
if !result.Finalized && result.TamperingDetected && result.TamperingReason != nil {
|
||||
<div class="bg-red/10 border border-red/30 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-red font-medium text-sm">Warning: Inconsistent Data</span>
|
||||
</div>
|
||||
<p class="text-red/80 text-xs">{ *result.TamperingReason }</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Score Display -->
|
||||
<div class="flex items-center justify-center gap-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
if homeWon {
|
||||
<span class="text-2xl">🏆</span>
|
||||
}
|
||||
if fixture.HomeTeam.Color != "" {
|
||||
<span
|
||||
class="px-2.5 py-1 rounded text-sm font-bold"
|
||||
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
||||
fixture.HomeTeam.Color, fixture.HomeTeam.Color, fixture.HomeTeam.Color) }
|
||||
>
|
||||
{ fixture.HomeTeam.ShortName }
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||||
{ fixture.HomeTeam.ShortName }
|
||||
</span>
|
||||
}
|
||||
<span class="text-4xl font-bold text-text">{ fmt.Sprint(result.HomeScore) }</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-4xl text-subtext0 font-light leading-none">–</span>
|
||||
if isOT {
|
||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-semibold mt-1">
|
||||
OT
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-4xl font-bold text-text">{ fmt.Sprint(result.AwayScore) }</span>
|
||||
if fixture.AwayTeam.Color != "" {
|
||||
<span
|
||||
class="px-2.5 py-1 rounded text-sm font-bold"
|
||||
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
||||
fixture.AwayTeam.Color, fixture.AwayTeam.Color, fixture.AwayTeam.Color) }
|
||||
>
|
||||
{ fixture.AwayTeam.ShortName }
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||||
{ fixture.AwayTeam.ShortName }
|
||||
</span>
|
||||
}
|
||||
if awayWon {
|
||||
<span class="text-2xl">🏆</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ fixtureUploadPrompt(fixture *db.Fixture) {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||||
<div class="text-4xl mb-3">📋</div>
|
||||
<p class="text-lg text-text font-medium mb-2">No Result Uploaded</p>
|
||||
<p class="text-sm text-subtext1 mb-4">Upload match log files to record the result of this fixture.</p>
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/results/upload", fixture.ID)) }
|
||||
class="inline-block px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Upload Match Logs
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side string, result *db.FixtureResult) {
|
||||
{{
|
||||
// Separate playing and bench players
|
||||
var playing []*db.PlayerWithPlayStatus
|
||||
var bench []*db.PlayerWithPlayStatus
|
||||
for _, p := range players {
|
||||
if result == nil || p.Played {
|
||||
playing = append(playing, p)
|
||||
} else {
|
||||
bench = append(bench, p)
|
||||
}
|
||||
}
|
||||
showStats := result != nil && result.Finalized
|
||||
if showStats {
|
||||
// Sort playing players by score descending
|
||||
sort.Slice(playing, func(i, j int) bool {
|
||||
si, sj := 0, 0
|
||||
if playing[i].Stats != nil && playing[i].Stats.Score != nil {
|
||||
si = *playing[i].Stats.Score
|
||||
}
|
||||
if playing[j].Stats != nil && playing[j].Stats.Score != nil {
|
||||
sj = *playing[j].Stats.Score
|
||||
}
|
||||
return si > sj
|
||||
})
|
||||
} else {
|
||||
// Sort with managers first
|
||||
sort.SliceStable(playing, func(i, j int) bool {
|
||||
return playing[i].IsManager && !playing[j].IsManager
|
||||
})
|
||||
sort.SliceStable(bench, func(i, j int) bool {
|
||||
return bench[i].IsManager && !bench[j].IsManager
|
||||
})
|
||||
}
|
||||
}}
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||
<h3 class="text-md font-bold text-text">
|
||||
{ team.Name }
|
||||
</h3>
|
||||
if team.Color != "" {
|
||||
<span
|
||||
class="w-4 h-4 rounded-full border border-surface1"
|
||||
style={ fmt.Sprintf("background-color: %s", team.Color) }
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
if len(players) == 0 {
|
||||
<div class="p-4">
|
||||
<p class="text-subtext1 text-sm text-center py-2">No players on roster.</p>
|
||||
</div>
|
||||
} else if showStats {
|
||||
<!-- Stats table view for finalized results -->
|
||||
if len(playing) > 0 {
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-surface0 border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for _, p := range playing {
|
||||
<tr class="hover:bg-surface0 transition-colors">
|
||||
<td class="px-3 py-2 text-sm text-text">
|
||||
<span class="flex items-center gap-1.5">
|
||||
{ p.Player.DisplayName() }
|
||||
if p.IsManager {
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
★
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
if p.Stats != nil {
|
||||
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ intPtrStr(p.Stats.Score) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Goals) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Assists) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Saves) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Shots) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Blocks) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Passes) }</td>
|
||||
} else {
|
||||
<td colspan="7" class="px-2 py-2 text-center text-xs text-subtext1">—</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
if len(bench) > 0 {
|
||||
<div class="border-t border-surface1 px-4 py-3">
|
||||
<p class="text-xs text-subtext0 font-semibold uppercase mb-2">Bench</p>
|
||||
<div class="space-y-1">
|
||||
for _, p := range bench {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded">
|
||||
<span class="text-sm text-subtext1">
|
||||
{ p.Player.DisplayName() }
|
||||
</span>
|
||||
if p.IsManager {
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
★ Manager
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
<!-- Simple list view (no result or pending review) -->
|
||||
<div class="p-4">
|
||||
if len(playing) > 0 {
|
||||
if result != nil {
|
||||
<p class="text-xs text-subtext0 font-semibold uppercase mb-2">Playing</p>
|
||||
}
|
||||
<div class="space-y-1">
|
||||
for _, p := range playing {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface0 transition">
|
||||
<span class="text-sm text-text">
|
||||
{ p.Player.DisplayName() }
|
||||
</span>
|
||||
if p.IsManager {
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
★ Manager
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if result != nil && len(bench) > 0 {
|
||||
<p class="text-xs text-subtext0 font-semibold uppercase mt-4 mb-2">Bench</p>
|
||||
<div class="space-y-1">
|
||||
for _, p := range bench {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded">
|
||||
<span class="text-sm text-subtext1">
|
||||
{ p.Player.DisplayName() }
|
||||
</span>
|
||||
if p.IsManager {
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
★ Manager
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// ==================== Schedule Tab ====================
|
||||
templ fixtureScheduleTab(
|
||||
fixture *db.Fixture,
|
||||
currentSchedule *db.FixtureSchedule,
|
||||
history []*db.FixtureSchedule,
|
||||
canSchedule bool,
|
||||
canManage bool,
|
||||
userTeamID int,
|
||||
) {
|
||||
<div class="space-y-6">
|
||||
@fixtureScheduleStatus(fixture, currentSchedule, canSchedule, canManage, userTeamID)
|
||||
@fixtureScheduleActions(fixture, currentSchedule, canSchedule, canManage, userTeamID)
|
||||
@fixtureScheduleHistory(fixture, history)
|
||||
</div>
|
||||
}
|
||||
|
||||
templ fixtureScheduleStatus(
|
||||
fixture *db.Fixture,
|
||||
current *db.FixtureSchedule,
|
||||
@@ -108,7 +528,8 @@ templ fixtureScheduleStatus(
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl mb-3">⏳</div>
|
||||
<p class="text-lg text-text font-medium">
|
||||
Proposed: { formatScheduleTime(current.ScheduledTime) }
|
||||
Proposed:
|
||||
@localtime(current.ScheduledTime, "datetime")
|
||||
</p>
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
Proposed by
|
||||
@@ -161,7 +582,8 @@ templ fixtureScheduleStatus(
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl mb-3">✅</div>
|
||||
<p class="text-lg text-green font-medium">
|
||||
Confirmed: { formatScheduleTime(current.ScheduledTime) }
|
||||
Confirmed:
|
||||
@localtime(current.ScheduledTime, "datetime")
|
||||
</p>
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
Both teams have agreed on this time.
|
||||
@@ -278,7 +700,19 @@ templ fixtureScheduleActions(
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-subtext0 mb-1">Date & Time</label>
|
||||
<label class="block text-sm font-medium text-subtext0 mb-1">
|
||||
Date & Time
|
||||
<span class="relative group inline-block ml-1">
|
||||
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium cursor-help">AEST/AEDT</span>
|
||||
<span
|
||||
class="absolute left-0 top-full mt-1 w-56 bg-crust border border-surface1 rounded-lg
|
||||
p-2 text-xs text-subtext0 shadow-lg opacity-0 invisible
|
||||
group-hover:opacity-100 group-hover:visible transition-all z-50"
|
||||
>
|
||||
Enter the time in Australian Eastern time (AEST/AEDT). It will be displayed in each user's local timezone.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="scheduled_time"
|
||||
@@ -311,7 +745,19 @@ templ fixtureScheduleActions(
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-subtext0 mb-1">New Date & Time</label>
|
||||
<label class="block text-sm font-medium text-subtext0 mb-1">
|
||||
New Date & Time
|
||||
<span class="relative group inline-block ml-1">
|
||||
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium cursor-help">AEST/AEDT</span>
|
||||
<span
|
||||
class="absolute left-0 top-full mt-1 w-56 bg-crust border border-surface1 rounded-lg
|
||||
p-2 text-xs text-subtext0 shadow-lg opacity-0 invisible
|
||||
group-hover:opacity-100 group-hover:visible transition-all z-50"
|
||||
>
|
||||
Enter the time in Australian Eastern time (AEST/AEDT). It will be displayed in each user's local timezone.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="scheduled_time"
|
||||
@@ -507,7 +953,7 @@ templ scheduleHistoryItem(schedule *db.FixtureSchedule, isCurrent bool) {
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-subtext1">
|
||||
{ formatHistoryTime(schedule.CreatedAt) }
|
||||
@localtimeUnix(schedule.CreatedAt, "histdate")
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
@@ -518,7 +964,9 @@ templ scheduleHistoryItem(schedule *db.FixtureSchedule, isCurrent bool) {
|
||||
if schedule.ScheduledTime != nil {
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-subtext0">Time:</span>
|
||||
<span class="text-text">{ formatScheduleTime(schedule.ScheduledTime) }</span>
|
||||
<span class="text-text">
|
||||
@localtime(schedule.ScheduledTime, "datetime")
|
||||
</span>
|
||||
</div>
|
||||
} else {
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
|
||||
234
internal/view/seasonsview/fixture_review_result.templ
Normal file
234
internal/view/seasonsview/fixture_review_result.templ
Normal file
@@ -0,0 +1,234 @@
|
||||
package seasonsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||
import "fmt"
|
||||
|
||||
templ FixtureReviewResultPage(
|
||||
fixture *db.Fixture,
|
||||
result *db.FixtureResult,
|
||||
unmappedPlayers []string,
|
||||
) {
|
||||
{{
|
||||
backURL := fmt.Sprintf("/fixtures/%d", fixture.ID)
|
||||
}}
|
||||
@baseview.Layout(fmt.Sprintf("Review Result — %s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
|
||||
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-6">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-text mb-1">Review Match Result</h1>
|
||||
<p class="text-sm text-subtext1">
|
||||
{ fixture.HomeTeam.Name } vs { fixture.AwayTeam.Name }
|
||||
<span class="text-subtext0 ml-1">
|
||||
Round { fmt.Sprint(fixture.Round) }
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={ templ.SafeURL(backURL) }
|
||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||
bg-surface1 hover:bg-surface2 text-text transition"
|
||||
>
|
||||
Back to Fixture
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Warnings Section -->
|
||||
if result.TamperingDetected || len(unmappedPlayers) > 0 {
|
||||
<div class="space-y-4 mb-6">
|
||||
if result.TamperingDetected && result.TamperingReason != nil {
|
||||
<div class="bg-red/10 border border-red/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-red font-bold text-sm">⚠ Inconsistent Data Detected</span>
|
||||
</div>
|
||||
<p class="text-red/80 text-sm">{ *result.TamperingReason }</p>
|
||||
<p class="text-red/60 text-xs mt-2">
|
||||
This does not block finalization but should be reviewed carefully.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
if len(unmappedPlayers) > 0 {
|
||||
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-yellow font-bold text-sm">⚠ Unmapped Players</span>
|
||||
</div>
|
||||
<p class="text-yellow/80 text-sm mb-2">
|
||||
The following players could not be matched to registered players.
|
||||
They may be free agents or have unregistered Slapshot IDs.
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
|
||||
for _, p := range unmappedPlayers {
|
||||
<li>{ p }</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Score Overview -->
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Score</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-center gap-8 py-4">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-subtext0 mb-1">{ fixture.HomeTeam.Name }</p>
|
||||
<p class="text-4xl font-bold text-text">{ fmt.Sprint(result.HomeScore) }</p>
|
||||
</div>
|
||||
<div class="text-2xl text-subtext0 font-light">—</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-subtext0 mb-1">{ fixture.AwayTeam.Name }</p>
|
||||
<p class="text-4xl font-bold text-text">{ fmt.Sprint(result.AwayScore) }</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 mt-2 text-xs text-subtext1">
|
||||
if result.Arena != "" {
|
||||
<span>{ result.Arena }</span>
|
||||
}
|
||||
if result.EndReason != "" {
|
||||
<span>{ result.EndReason }</span>
|
||||
}
|
||||
<span>
|
||||
Winner:
|
||||
if result.Winner == "home" {
|
||||
{ fixture.HomeTeam.Name }
|
||||
} else if result.Winner == "away" {
|
||||
{ fixture.AwayTeam.Name }
|
||||
} else {
|
||||
Draw
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Player Stats Tables -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
@reviewTeamStats(fixture.HomeTeam, result, "home")
|
||||
@reviewTeamStats(fixture.AwayTeam, result, "away")
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Actions</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/fixtures/%d/results/finalize", fixture.ID) }
|
||||
hx-swap="none"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-6 py-3 bg-green hover:bg-green/75 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer text-lg"
|
||||
>
|
||||
Finalize Result
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
@click={ fmt.Sprintf("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Discard Result', message: 'Are you sure you want to discard this result? You will need to re-upload the match logs.', action: () => htmx.ajax('POST', '/fixtures/%d/results/discard', { swap: 'none' }) } }))", fixture.ID) }
|
||||
class="px-6 py-3 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer text-lg"
|
||||
>
|
||||
Discard & Re-upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
||||
{{
|
||||
// Collect unique players for this team across all periods
|
||||
// We'll show the period 3 (final/cumulative) stats
|
||||
type playerStat struct {
|
||||
Username string
|
||||
PlayerID *int
|
||||
Stats *db.FixtureResultPlayerStats
|
||||
}
|
||||
finalStats := []*playerStat{}
|
||||
seen := map[string]bool{}
|
||||
// Find period 3 stats for this team (cumulative)
|
||||
for _, ps := range result.PlayerStats {
|
||||
if ps.Team == side && ps.PeriodNum == 3 {
|
||||
if !seen[ps.PlayerGameUserID] {
|
||||
seen[ps.PlayerGameUserID] = true
|
||||
finalStats = append(finalStats, &playerStat{
|
||||
Username: ps.PlayerUsername,
|
||||
PlayerID: ps.PlayerID,
|
||||
Stats: ps,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||
<h3 class="text-md font-bold text-text">
|
||||
if side == "home" {
|
||||
Home —
|
||||
} else {
|
||||
Away —
|
||||
}
|
||||
{ team.Name }
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-surface0 border-b border-surface1">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th>
|
||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for _, ps := range finalStats {
|
||||
<tr class="hover:bg-surface0 transition-colors">
|
||||
<td class="px-3 py-2 text-sm text-text">
|
||||
{ ps.Username }
|
||||
if ps.PlayerID == nil {
|
||||
<span class="text-yellow text-xs ml-1" title="Unmapped player">?</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Goals) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Assists) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Saves) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Shots) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Blocks) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Passes) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Score) }</td>
|
||||
</tr>
|
||||
}
|
||||
if len(finalStats) == 0 {
|
||||
<tr>
|
||||
<td colspan="8" class="px-3 py-4 text-center text-sm text-subtext1">
|
||||
No player stats recorded
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
func intPtrStr(v *int) string {
|
||||
if v == nil {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprint(*v)
|
||||
}
|
||||
118
internal/view/seasonsview/fixture_upload_result.templ
Normal file
118
internal/view/seasonsview/fixture_upload_result.templ
Normal file
@@ -0,0 +1,118 @@
|
||||
package seasonsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||
import "fmt"
|
||||
|
||||
templ FixtureUploadResultPage(fixture *db.Fixture) {
|
||||
{{
|
||||
backURL := fmt.Sprintf("/fixtures/%d", fixture.ID)
|
||||
}}
|
||||
@baseview.Layout(fmt.Sprintf("Upload Result — %s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
|
||||
<div class="max-w-screen-md mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
||||
<div class="bg-surface0 border-b border-surface1 px-6 py-6">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-text mb-1">Upload Match Logs</h1>
|
||||
<p class="text-sm text-subtext1">
|
||||
{ fixture.HomeTeam.Name } vs { fixture.AwayTeam.Name }
|
||||
<span class="text-subtext0 ml-1">
|
||||
Round { fmt.Sprint(fixture.Round) }
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={ templ.SafeURL(backURL) }
|
||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||
bg-surface1 hover:bg-surface2 text-text transition"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload Form -->
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||
<h2 class="text-lg font-bold text-text">Match Log Files</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-sm text-subtext1 mb-6">
|
||||
Upload the 3 period match log JSON files. Each file corresponds to one period of the match.
|
||||
The files will be validated for consistency.
|
||||
</p>
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/fixtures/%d/results/upload", fixture.ID) }
|
||||
hx-swap="none"
|
||||
hx-encoding="multipart/form-data"
|
||||
class="space-y-6"
|
||||
>
|
||||
<!-- Period 1 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text mb-2">
|
||||
Period 1
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="period_1"
|
||||
accept=".json"
|
||||
required
|
||||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||
file:mr-4 file:py-1 file:px-3 file:rounded file:border-0
|
||||
file:text-sm file:font-medium file:bg-blue file:text-mantle
|
||||
file:hover:bg-blue/80 file:hover:cursor-pointer file:transition
|
||||
focus:border-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<!-- Period 2 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text mb-2">
|
||||
Period 2
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="period_2"
|
||||
accept=".json"
|
||||
required
|
||||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||
file:mr-4 file:py-1 file:px-3 file:rounded file:border-0
|
||||
file:text-sm file:font-medium file:bg-blue file:text-mantle
|
||||
file:hover:bg-blue/80 file:hover:cursor-pointer file:transition
|
||||
focus:border-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<!-- Period 3 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text mb-2">
|
||||
Period 3
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="period_3"
|
||||
accept=".json"
|
||||
required
|
||||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||
file:mr-4 file:py-1 file:px-3 file:rounded file:border-0
|
||||
file:text-sm file:font-medium file:bg-blue file:text-mantle
|
||||
file:hover:bg-blue/80 file:hover:cursor-pointer file:transition
|
||||
focus:border-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<!-- Submit -->
|
||||
<div class="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full px-4 py-3 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer text-lg"
|
||||
>
|
||||
Upload & Validate
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
43
internal/view/seasonsview/localtime.templ
Normal file
43
internal/view/seasonsview/localtime.templ
Normal file
@@ -0,0 +1,43 @@
|
||||
package seasonsview
|
||||
|
||||
import "time"
|
||||
|
||||
// formatISO returns an ISO 8601 UTC string for use in <time datetime="...">.
|
||||
func formatISO(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// formatISOUnix returns an ISO 8601 UTC string from a Unix timestamp.
|
||||
func formatISOUnix(unix int64) string {
|
||||
return time.Unix(unix, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// localtime renders a <time> element that will be formatted client-side
|
||||
// in the user's local timezone. The format parameter maps to data-localtime:
|
||||
// "date" → "Mon 2 Jan 2026"
|
||||
// "time" → "3:04 PM"
|
||||
// "datetime" → "Mon 2 Jan 2026 at 3:04 PM"
|
||||
// "short" → "Mon 2 Jan 3:04 PM"
|
||||
// "histdate" → "2 Jan 2006 15:04"
|
||||
templ localtime(t *time.Time, format string) {
|
||||
if t != nil {
|
||||
<time datetime={ formatISO(t) } data-localtime={ format }>
|
||||
{ t.UTC().Format("2 Jan 2006 15:04 UTC") }
|
||||
</time>
|
||||
} else {
|
||||
No time set
|
||||
}
|
||||
}
|
||||
|
||||
// localtimeUnix renders a <time> element from a Unix timestamp.
|
||||
templ localtimeUnix(unix int64, format string) {
|
||||
{{
|
||||
t := time.Unix(unix, 0)
|
||||
}}
|
||||
<time datetime={ formatISOUnix(unix) } data-localtime={ format }>
|
||||
{ t.UTC().Format("2 Jan 2006 15:04 UTC") }
|
||||
</time>
|
||||
}
|
||||
@@ -4,14 +4,16 @@ import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||
import "fmt"
|
||||
import "sort"
|
||||
import "time"
|
||||
|
||||
templ SeasonLeagueFixturesPage(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
|
||||
templ SeasonLeagueFixturesPage(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) {
|
||||
@SeasonLeagueLayout("fixtures", season, league) {
|
||||
@SeasonLeagueFixtures(season, league, fixtures, scheduleMap)
|
||||
@SeasonLeagueFixtures(season, league, fixtures, scheduleMap, resultMap)
|
||||
}
|
||||
}
|
||||
|
||||
templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
|
||||
templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) {
|
||||
{{
|
||||
permCache := contexts.Permissions(ctx)
|
||||
canManage := permCache.HasPermission(permissions.FixturesManage)
|
||||
@@ -35,6 +37,23 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
|
||||
}
|
||||
groups[idx].Fixtures = append(groups[idx].Fixtures, f)
|
||||
}
|
||||
|
||||
// Sort fixtures within each group by scheduled time
|
||||
// Scheduled fixtures first (by time), then TBD last
|
||||
farFuture := time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i := range groups {
|
||||
sort.Slice(groups[i].Fixtures, func(a, b int) bool {
|
||||
ta := farFuture
|
||||
tb := farFuture
|
||||
if sa, ok := scheduleMap[groups[i].Fixtures[a].ID]; ok && sa.ScheduledTime != nil {
|
||||
ta = *sa.ScheduledTime
|
||||
}
|
||||
if sb, ok := scheduleMap[groups[i].Fixtures[b].ID]; ok && sb.ScheduledTime != nil {
|
||||
tb = *sb.ScheduledTime
|
||||
}
|
||||
return ta.Before(tb)
|
||||
})
|
||||
}
|
||||
}}
|
||||
<div>
|
||||
if canManage {
|
||||
@@ -55,47 +74,113 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
|
||||
} else {
|
||||
<div class="space-y-4">
|
||||
for _, group := range groups {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-mantle border-b border-surface1 px-4 py-3">
|
||||
{{
|
||||
playedCount := 0
|
||||
for _, f := range group.Fixtures {
|
||||
if res, ok := resultMap[f.ID]; ok && res.Finalized {
|
||||
playedCount++
|
||||
}
|
||||
}
|
||||
hasPlayed := playedCount > 0
|
||||
allPlayed := playedCount == len(group.Fixtures)
|
||||
}}
|
||||
<div
|
||||
class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"
|
||||
x-data="{ showPlayed: false }"
|
||||
>
|
||||
<div class="bg-mantle border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold text-text">Game Week { fmt.Sprint(group.Week) }</h3>
|
||||
if hasPlayed {
|
||||
<button
|
||||
type="button"
|
||||
@click="showPlayed = !showPlayed"
|
||||
class="text-xs px-2.5 py-1 rounded-lg transition cursor-pointer
|
||||
bg-surface1 hover:bg-surface2 text-subtext0 hover:text-text"
|
||||
>
|
||||
<span x-show="!showPlayed">Show played</span>
|
||||
<span x-show="showPlayed" x-cloak>Hide played</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="divide-y divide-surface1">
|
||||
for _, fixture := range group.Fixtures {
|
||||
{{
|
||||
sched, hasSchedule := scheduleMap[fixture.ID]
|
||||
_ = sched
|
||||
res, hasResult := resultMap[fixture.ID]
|
||||
_ = res
|
||||
isPlayed := hasResult && res.Finalized
|
||||
}}
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
||||
class="px-4 py-3 flex items-center justify-between hover:bg-surface1 transition hover:cursor-pointer block"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded">
|
||||
R{ fmt.Sprint(fixture.Round) }
|
||||
</span>
|
||||
<span class="text-text">
|
||||
{ fixture.HomeTeam.Name }
|
||||
</span>
|
||||
<span class="text-subtext0 text-sm">vs</span>
|
||||
<span class="text-text">
|
||||
{ fixture.AwayTeam.Name }
|
||||
</span>
|
||||
</div>
|
||||
if hasSchedule && sched.ScheduledTime != nil {
|
||||
<span class="text-xs text-green font-medium">
|
||||
{ sched.ScheduledTime.Format("Mon 2 Jan 3:04 PM") }
|
||||
</span>
|
||||
} else {
|
||||
<span class="text-xs text-subtext1">
|
||||
TBD
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
if isPlayed {
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
||||
x-show="showPlayed"
|
||||
x-cloak
|
||||
class="px-4 py-3 flex items-center justify-between hover:bg-surface1 transition hover:cursor-pointer block"
|
||||
>
|
||||
@fixtureListItem(fixture, sched, hasSchedule, res, hasResult)
|
||||
</a>
|
||||
} else {
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
||||
class="px-4 py-3 flex items-center justify-between hover:bg-surface1 transition hover:cursor-pointer block"
|
||||
>
|
||||
@fixtureListItem(fixture, sched, hasSchedule, res, hasResult)
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
if allPlayed {
|
||||
<div
|
||||
x-show="!showPlayed"
|
||||
class="px-4 py-3 text-center text-xs text-subtext1 italic"
|
||||
>
|
||||
All fixtures played
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ fixtureListItem(fixture *db.Fixture, sched *db.FixtureSchedule, hasSchedule bool, res *db.FixtureResult, hasResult bool) {
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded">
|
||||
R{ fmt.Sprint(fixture.Round) }
|
||||
</span>
|
||||
<span class="text-text">
|
||||
{ fixture.HomeTeam.Name }
|
||||
</span>
|
||||
<span class="text-subtext0 text-sm">vs</span>
|
||||
<span class="text-text">
|
||||
{ fixture.AwayTeam.Name }
|
||||
</span>
|
||||
</div>
|
||||
if hasResult {
|
||||
<span class="flex items-center gap-2">
|
||||
if res.Winner == "home" {
|
||||
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
||||
<span class="text-xs text-subtext0">–</span>
|
||||
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
|
||||
} else if res.Winner == "away" {
|
||||
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
|
||||
<span class="text-xs text-subtext0">–</span>
|
||||
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
||||
} else {
|
||||
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
||||
<span class="text-xs text-subtext0">–</span>
|
||||
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
||||
}
|
||||
</span>
|
||||
} else if hasSchedule && sched.ScheduledTime != nil {
|
||||
<span class="text-xs text-green font-medium">
|
||||
@localtime(sched.ScheduledTime, "short")
|
||||
</span>
|
||||
} else {
|
||||
<span class="text-xs text-subtext1">
|
||||
TBD
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,7 +458,7 @@ templ teamFixtureRow(team *db.Team, fixture *db.Fixture, scheduleMap map[int]*db
|
||||
</div>
|
||||
if hasSchedule && sched.ScheduledTime != nil {
|
||||
<span class="text-xs text-green font-medium shrink-0">
|
||||
{ sched.ScheduledTime.Format("Mon 2 Jan 3:04 PM") }
|
||||
@localtime(sched.ScheduledTime, "short")
|
||||
</span>
|
||||
} else {
|
||||
<span class="text-xs text-subtext1 shrink-0">
|
||||
|
||||
Reference in New Issue
Block a user