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

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