added log file uploading and match results

This commit is contained in:
2026-02-21 22:25:21 +11:00
parent 6439bf782b
commit 680ba3fe50
20 changed files with 2595 additions and 61 deletions

View File

@@ -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();

View File

@@ -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">&#9888;</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">&#127942;</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">&#127942;</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">
&#9733;
</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">
&#9733; 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">
&#9733; 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">
&#9733; 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">

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

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

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

View File

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

View File

@@ -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">