series overview added

This commit is contained in:
2026-03-15 12:05:47 +11:00
parent 3666870111
commit fc7f5665e7
12 changed files with 3133 additions and 1 deletions

View File

@@ -44,6 +44,15 @@ templ PlayoffBracketView(season *db.Season, league *db.League, bracket *db.Playo
</div>
</div>
<script src="/static/js/bracket-lines.js"></script>
<script>
document.querySelectorAll('[data-series-url]').forEach(function(card) {
card.addEventListener('click', function(e) {
if (!e.target.closest('a')) {
window.location.href = card.getAttribute('data-series-url');
}
});
});
</script>
}
// ──────────────────────────────────────────────
@@ -189,11 +198,19 @@ templ bracket10to15(season *db.Season, league *db.League, bracket *db.PlayoffBra
// ──────────────────────────────────────────────
templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) {
{{
hasTeams := series.Team1 != nil || series.Team2 != nil
seriesURL := fmt.Sprintf("/series/%d", series.ID)
}}
<div
data-series={ fmt.Sprint(series.SeriesNumber) }
if hasTeams {
data-series-url={ seriesURL }
}
class={ "bg-surface0 border rounded-lg overflow-hidden",
templ.KV("border-blue/50", series.Status == db.SeriesStatusInProgress),
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress) }
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress),
templ.KV("hover:bg-surface1 hover:cursor-pointer transition", hasTeams) }
>
<!-- Series Header -->
<div class="bg-mantle px-3 py-1.5 flex items-center justify-between border-b border-surface1">

View File

@@ -0,0 +1,611 @@
package seasonsview
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 "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt"
import "sort"
import "strings"
// seriesTeamName returns a display name for a team in the series, or "TBD" if nil
func seriesTeamName(team *db.Team) string {
if team == nil {
return "TBD"
}
return team.Name
}
// seriesTeamShortName returns a short name for a team in the series, or "TBD" if nil
func seriesTeamShortName(team *db.Team) string {
if team == nil {
return "TBD"
}
return team.ShortName
}
// roundDisplayName converts a round slug to a human-readable name
func roundDisplayName(round string) string {
switch round {
case "upper_bracket":
return "Upper Bracket"
case "lower_bracket":
return "Lower Bracket"
case "upper_final":
return "Upper Final"
case "lower_final":
return "Lower Final"
case "quarter_final":
return "Quarter Final"
case "semi_final":
return "Semi Final"
case "elimination_final":
return "Elimination Final"
case "qualifying_final":
return "Qualifying Final"
case "preliminary_final":
return "Preliminary Final"
case "third_place":
return "Third Place Playoff"
case "grand_final":
return "Grand Final"
default:
return strings.ReplaceAll(round, "_", " ")
}
}
// SeriesDetailLayout renders the series detail page layout with header and
// tab navigation. Tab content is rendered as children.
templ SeriesDetailLayout(activeTab string, series *db.PlayoffSeries, schedule *db.PlayoffSeriesSchedule) {
{{
backURL := fmt.Sprintf("/seasons/%s/leagues/%s/finals",
series.Bracket.Season.ShortName, series.Bracket.League.ShortName)
isCompleted := series.Status == db.SeriesStatusCompleted
team1Name := seriesTeamName(series.Team1)
team2Name := seriesTeamName(series.Team2)
boLabel := fmt.Sprintf("BO%d", series.MatchesToWin*2-1)
}}
@baseview.Layout(fmt.Sprintf("%s — %s vs %s", series.Label, team1Name, team2Name)) {
<div class="max-w-screen-lg 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-8">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<div class="flex items-center gap-4 mb-2">
<h1 class="text-3xl font-bold text-text">
{ team1Name }
<span class="text-subtext0 font-normal">vs</span>
{ team2Name }
</h1>
</div>
<div class="flex items-center gap-2 flex-wrap">
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
{ series.Label }
</span>
<span class="px-2 py-0.5 bg-surface1 text-subtext0 rounded text-xs font-mono">
{ boLabel }
</span>
if series.Team1Seed != nil || series.Team2Seed != nil {
<span class="px-2 py-0.5 bg-surface1 text-subtext0 rounded text-xs font-mono">
if series.Team1Seed != nil && series.Team2Seed != nil {
Seed { fmt.Sprint(*series.Team1Seed) } vs { fmt.Sprint(*series.Team2Seed) }
} else if series.Team1Seed != nil {
Seed { fmt.Sprint(*series.Team1Seed) }
} else if series.Team2Seed != nil {
Seed { fmt.Sprint(*series.Team2Seed) }
}
</span>
}
<span class="text-subtext1 text-sm">
{ series.Bracket.Season.Name } — { series.Bracket.League.Name }
</span>
</div>
</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 Bracket
</a>
</div>
</div>
<!-- Tab Navigation -->
<nav class="bg-surface0 border-b border-surface1" data-tab-nav="series-detail-content">
<ul class="flex flex-wrap">
@seriesTabItem("overview", "Overview", activeTab, series)
if isCompleted {
@seriesTabItem("analysis", "Match Analysis", activeTab, series)
} else {
@seriesTabItem("preview", "Match Preview", activeTab, series)
@seriesTabItem("scheduling", "Schedule", activeTab, series)
}
</ul>
</nav>
</div>
<!-- Content Area -->
<main id="series-detail-content">
{ children... }
</main>
</div>
<script src="/static/js/tabs.js" defer></script>
}
}
templ seriesTabItem(section string, label string, activeTab string, series *db.PlayoffSeries) {
{{
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("/series/%d/%s", series.ID, section)
}}
<li class="inline-block">
<a
href={ templ.SafeURL(url) }
hx-post={ url }
hx-target="#series-detail-content"
hx-swap="innerHTML"
hx-push-url={ url }
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
>
{ label }
</a>
</li>
}
// ==================== Full page wrappers (for GET requests / direct navigation) ====================
templ SeriesDetailOverviewPage(
series *db.PlayoffSeries,
currentSchedule *db.PlayoffSeriesSchedule,
canSchedule bool,
userTeamID int,
rosters map[string][]*db.PlayerWithPlayStatus,
) {
@SeriesDetailLayout("overview", series, currentSchedule) {
@SeriesDetailOverviewContent(series, currentSchedule, canSchedule, userTeamID, rosters)
}
}
templ SeriesDetailPreviewPage(
series *db.PlayoffSeries,
currentSchedule *db.PlayoffSeriesSchedule,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@SeriesDetailLayout("preview", series, currentSchedule) {
@SeriesDetailPreviewContent(series, rosters, previewData)
}
}
templ SeriesDetailAnalysisPage(
series *db.PlayoffSeries,
currentSchedule *db.PlayoffSeriesSchedule,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@SeriesDetailLayout("analysis", series, currentSchedule) {
@SeriesDetailAnalysisContent(series, rosters, previewData)
}
}
templ SeriesDetailSchedulePage(
series *db.PlayoffSeries,
currentSchedule *db.PlayoffSeriesSchedule,
history []*db.PlayoffSeriesSchedule,
canSchedule bool,
userTeamID int,
) {
@SeriesDetailLayout("scheduling", series, currentSchedule) {
@SeriesDetailScheduleContent(series, currentSchedule, history, canSchedule, userTeamID)
}
}
// ==================== Tab content components (for POST requests / HTMX swaps) ====================
templ SeriesDetailOverviewContent(
series *db.PlayoffSeries,
currentSchedule *db.PlayoffSeriesSchedule,
canSchedule bool,
userTeamID int,
rosters map[string][]*db.PlayerWithPlayStatus,
) {
{{
permCache := contexts.Permissions(ctx)
canManage := permCache.HasPermission(permissions.PlayoffsManage)
_ = canManage
}}
@seriesOverviewTab(series, currentSchedule, rosters, canSchedule, userTeamID)
}
templ SeriesDetailPreviewContent(
series *db.PlayoffSeries,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@seriesMatchPreviewTab(series, rosters, previewData)
}
templ SeriesDetailAnalysisContent(
series *db.PlayoffSeries,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@seriesMatchAnalysisTab(series, rosters, previewData)
}
templ SeriesDetailScheduleContent(
series *db.PlayoffSeries,
currentSchedule *db.PlayoffSeriesSchedule,
history []*db.PlayoffSeriesSchedule,
canSchedule bool,
userTeamID int,
) {
{{
permCache := contexts.Permissions(ctx)
canManage := permCache.HasPermission(permissions.PlayoffsManage)
}}
@seriesScheduleTab(series, currentSchedule, history, canSchedule, canManage, userTeamID)
}
// ==================== Overview Tab ====================
templ seriesOverviewTab(
series *db.PlayoffSeries,
currentSchedule *db.PlayoffSeriesSchedule,
rosters map[string][]*db.PlayerWithPlayStatus,
canSchedule bool,
userTeamID int,
) {
<div class="space-y-6">
<!-- Series Score + Schedule Row -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
@seriesScoreDisplay(series)
</div>
<div>
@seriesScheduleSummary(series, currentSchedule)
</div>
</div>
<!-- Match List -->
if len(series.Matches) > 0 {
@seriesMatchList(series)
}
<!-- Series Context -->
@seriesContextCard(series)
<!-- Team Rosters -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
if series.Team1 != nil {
@seriesTeamSection(series.Team1, rosters["team1"], series.Bracket.Season, series.Bracket.League)
}
if series.Team2 != nil {
@seriesTeamSection(series.Team2, rosters["team2"], series.Bracket.Season, series.Bracket.League)
}
</div>
</div>
}
templ seriesScoreDisplay(series *db.PlayoffSeries) {
{{
isCompleted := series.Status == db.SeriesStatusCompleted
team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID
team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID
isBye := series.Status == db.SeriesStatusBye
}}
<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">Series Score</h2>
<div class="flex items-center gap-2">
@seriesStatusBadge(series.Status)
@seriesFormatBadge(series.MatchesToWin)
</div>
</div>
<div class="p-6">
if isBye {
<div class="text-center py-4">
<p class="text-lg text-subtext0">Bye — team advances automatically</p>
</div>
} else if series.Team1 == nil && series.Team2 == nil {
<div class="text-center py-4">
<p class="text-lg text-subtext0">Teams not yet determined</p>
</div>
} else {
<div class="flex items-center justify-center gap-6 py-4">
<div class="flex items-center gap-3">
if team1Won {
<span class="text-2xl">&#127942;</span>
}
if series.Team1 != nil && series.Team1.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",
series.Team1.Color, series.Team1.Color, series.Team1.Color) }
>
{ seriesTeamShortName(series.Team1) }
</span>
} else {
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
{ seriesTeamShortName(series.Team1) }
</span>
}
<span class="text-4xl font-bold text-text">{ fmt.Sprint(series.Team1Wins) }</span>
</div>
<div class="flex flex-col items-center">
<span class="text-4xl text-subtext0 font-light leading-none"></span>
if isCompleted {
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs font-semibold mt-1">
FINAL
</span>
}
</div>
<div class="flex items-center gap-3">
<span class="text-4xl font-bold text-text">{ fmt.Sprint(series.Team2Wins) }</span>
if series.Team2 != nil && series.Team2.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",
series.Team2.Color, series.Team2.Color, series.Team2.Color) }
>
{ seriesTeamShortName(series.Team2) }
</span>
} else {
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
{ seriesTeamShortName(series.Team2) }
</span>
}
if team2Won {
<span class="text-2xl">&#127942;</span>
}
</div>
</div>
}
</div>
</div>
}
templ seriesScheduleSummary(series *db.PlayoffSeries, schedule *db.PlayoffSeriesSchedule) {
{{
isCompleted := series.Status == db.SeriesStatusCompleted
}}
<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 isCompleted {
<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">
Cancelled
</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 seriesMatchList(series *db.PlayoffSeries) {
<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">Matches</h2>
</div>
<div class="divide-y divide-surface1">
for _, match := range series.Matches {
@seriesMatchRow(series, match)
}
</div>
</div>
}
templ seriesMatchRow(series *db.PlayoffSeries, match *db.PlayoffMatch) {
{{
matchLabel := fmt.Sprintf("Game %d", match.MatchNumber)
isPending := match.Status == "pending"
isCompleted := match.Status == "completed"
hasFixture := match.FixtureID != nil
_ = hasFixture
}}
<div class="flex items-center justify-between px-4 py-3 hover:bg-surface0 transition-colors">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-text">{ matchLabel }</span>
if isPending {
<span class="px-1.5 py-0.5 bg-surface1 text-subtext0 rounded text-xs">
Pending
</span>
} else if isCompleted {
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs">
Complete
</span>
} else {
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs">
{ match.Status }
</span>
}
</div>
if match.FixtureID != nil {
<a
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/overview", *match.FixtureID)) }
class="px-3 py-1 bg-surface1 hover:bg-surface2 text-text rounded text-xs
font-medium transition hover:cursor-pointer"
>
View Details
</a>
}
</div>
}
templ seriesContextCard(series *db.PlayoffSeries) {
{{
// Determine advancement info
winnerAdvances := ""
loserAdvances := ""
if series.WinnerNextID != nil {
// Look through bracket series for the target
if series.Bracket != nil {
for _, s := range series.Bracket.Series {
if s.ID == *series.WinnerNextID {
winnerAdvances = s.Label
break
}
}
}
if winnerAdvances == "" {
winnerAdvances = "next round"
}
}
if series.LoserNextID != nil {
if series.Bracket != nil {
for _, s := range series.Bracket.Series {
if s.ID == *series.LoserNextID {
loserAdvances = s.Label
break
}
}
}
if loserAdvances == "" {
loserAdvances = "next round"
}
}
}}
<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">Series Info</h2>
</div>
<div class="p-4 space-y-3">
<div class="flex items-center gap-3">
<span class="text-sm text-subtext0 w-24 shrink-0">Round</span>
<span class="text-sm font-medium text-text">{ roundDisplayName(series.Round) }</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-subtext0 w-24 shrink-0">Format</span>
<span class="text-sm font-medium text-text">Best of { fmt.Sprint(series.MatchesToWin*2 - 1) } (first to { fmt.Sprint(series.MatchesToWin) })</span>
</div>
if series.Team1Seed != nil && series.Team2Seed != nil {
<div class="flex items-center gap-3">
<span class="text-sm text-subtext0 w-24 shrink-0">Seeding</span>
<span class="text-sm font-medium text-text">
{ ordinal(*series.Team1Seed) } seed vs { ordinal(*series.Team2Seed) } seed
</span>
</div>
}
if winnerAdvances != "" {
<div class="flex items-center gap-3">
<span class="text-sm text-subtext0 w-24 shrink-0">Winner →</span>
<span class="text-sm font-medium text-green">{ winnerAdvances }</span>
</div>
} else {
<div class="flex items-center gap-3">
<span class="text-sm text-subtext0 w-24 shrink-0">Winner →</span>
<span class="text-sm font-medium text-yellow">Champion</span>
</div>
}
if loserAdvances != "" {
<div class="flex items-center gap-3">
<span class="text-sm text-subtext0 w-24 shrink-0">Loser →</span>
<span class="text-sm font-medium text-peach">{ loserAdvances }</span>
</div>
} else if series.WinnerNextID != nil {
<div class="flex items-center gap-3">
<span class="text-sm text-subtext0 w-24 shrink-0">Loser →</span>
<span class="text-sm font-medium text-red">Eliminated</span>
</div>
}
</div>
</div>
}
templ seriesTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, season *db.Season, league *db.League) {
{{
// Sort with managers first
sort.SliceStable(players, func(i, j int) bool {
return players[i].IsManager && !players[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">
@links.TeamNameLinkInSeason(team, season, league)
</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 {
<div class="p-4">
<div class="space-y-1">
for _, p := range players {
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface0 transition">
<span class="text-sm">
@links.PlayerLink(p.Player)
</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>
}
if p.IsFreeAgent {
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FREE AGENT
</span>
}
</div>
}
</div>
</div>
}
</div>
}

View File

@@ -0,0 +1,251 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt"
// seriesMatchAnalysisTab renders the full Match Analysis tab for completed series.
// Shows final series score, individual match results, aggregated team stats,
// top performers, and league context.
templ seriesMatchAnalysisTab(
series *db.PlayoffSeries,
rosters map[string][]*db.PlayerWithPlayStatus,
preview *db.MatchPreviewData,
) {
<div class="space-y-6">
<!-- Final Series Score -->
@seriesAnalysisScoreHeader(series)
<!-- Individual Match Results -->
if len(series.Matches) > 0 {
@seriesAnalysisMatchResults(series)
}
<!-- League Context (from preview data) -->
if preview != nil {
@seriesAnalysisLeagueContext(series, preview)
}
</div>
}
// seriesAnalysisScoreHeader renders the final series score in a prominent display.
templ seriesAnalysisScoreHeader(series *db.PlayoffSeries) {
{{
team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID
team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID
}}
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
<h2 class="text-lg font-bold text-text">Final Series Score</h2>
</div>
<div class="p-6">
<div class="flex items-center justify-center gap-6 sm:gap-10">
<!-- Team 1 -->
<div class="flex flex-col items-center text-center flex-1">
if series.Team1 != nil && series.Team1.Color != "" {
<div
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
></div>
}
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
if series.Team1 != nil {
@links.TeamNameLinkInSeason(series.Team1, series.Bracket.Season, series.Bracket.League)
} else {
TBD
}
</h3>
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", team1Won), templ.KV("text-text", !team1Won) }>
{ fmt.Sprint(series.Team1Wins) }
</span>
if team1Won {
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
}
</div>
<!-- Divider -->
<div class="flex flex-col items-center shrink-0">
<span class="text-4xl text-subtext0 font-light"></span>
</div>
<!-- Team 2 -->
<div class="flex flex-col items-center text-center flex-1">
if series.Team2 != nil && series.Team2.Color != "" {
<div
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
></div>
}
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
if series.Team2 != nil {
@links.TeamNameLinkInSeason(series.Team2, series.Bracket.Season, series.Bracket.League)
} else {
TBD
}
</h3>
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", team2Won), templ.KV("text-text", !team2Won) }>
{ fmt.Sprint(series.Team2Wins) }
</span>
if team2Won {
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
}
</div>
</div>
</div>
</div>
}
// seriesAnalysisMatchResults shows individual match results as a compact list.
templ seriesAnalysisMatchResults(series *db.PlayoffSeries) {
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
<h2 class="text-lg font-bold text-text">Match Results</h2>
</div>
<div class="divide-y divide-surface1">
for _, match := range series.Matches {
@seriesAnalysisMatchRow(series, match)
}
</div>
</div>
}
templ seriesAnalysisMatchRow(series *db.PlayoffSeries, match *db.PlayoffMatch) {
{{
matchLabel := fmt.Sprintf("Game %d", match.MatchNumber)
isCompleted := match.Status == "completed"
}}
<div class="flex items-center justify-between px-6 py-3 hover:bg-surface0 transition-colors">
<div class="flex items-center gap-4">
<span class="text-sm font-medium text-subtext0 w-16">{ matchLabel }</span>
if isCompleted {
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs">
Complete
</span>
} else {
<span class="px-1.5 py-0.5 bg-surface1 text-subtext0 rounded text-xs">
{ match.Status }
</span>
}
</div>
if match.FixtureID != nil {
<a
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/overview", *match.FixtureID)) }
class="px-3 py-1 bg-surface1 hover:bg-surface2 text-text rounded text-xs
font-medium transition hover:cursor-pointer"
>
View Details
</a>
}
</div>
}
// seriesAnalysisLeagueContext shows how the teams sit in the league standings.
templ seriesAnalysisLeagueContext(series *db.PlayoffSeries, preview *db.MatchPreviewData) {
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
<h2 class="text-lg font-bold text-text">League Context</h2>
</div>
<div class="p-6">
<!-- Team Name Headers -->
<div class="flex items-center mb-4">
<div class="flex-1 text-right pr-4">
<div class="flex items-center justify-end gap-2">
if series.Team1 != nil && series.Team1.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
></span>
}
<span class="text-sm font-bold text-text">{ seriesTeamShortName(series.Team1) }</span>
</div>
</div>
<div class="w-28 sm:w-36 text-center shrink-0"></div>
<div class="flex-1 text-left pl-4">
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-text">{ seriesTeamShortName(series.Team2) }</span>
if series.Team2 != nil && series.Team2.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
></span>
}
</div>
</div>
</div>
<div class="space-y-0">
{{
homePos := ordinal(preview.HomePosition)
awayPos := ordinal(preview.AwayPosition)
if preview.HomePosition == 0 {
homePos = "N/A"
}
if preview.AwayPosition == 0 {
awayPos = "N/A"
}
}}
@previewStatRow(
homePos,
"Position",
awayPos,
preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition,
preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition,
)
@previewStatRow(
fmt.Sprint(preview.HomeRecord.Points),
"Points",
fmt.Sprint(preview.AwayRecord.Points),
preview.HomeRecord.Points > preview.AwayRecord.Points,
preview.AwayRecord.Points > preview.HomeRecord.Points,
)
@previewStatRow(
fmt.Sprintf("%d-%d-%d-%d",
preview.HomeRecord.Wins,
preview.HomeRecord.OvertimeWins,
preview.HomeRecord.OvertimeLosses,
preview.HomeRecord.Losses,
),
"Record",
fmt.Sprintf("%d-%d-%d-%d",
preview.AwayRecord.Wins,
preview.AwayRecord.OvertimeWins,
preview.AwayRecord.OvertimeLosses,
preview.AwayRecord.Losses,
),
false,
false,
)
{{
homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst
awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst
}}
@previewStatRow(
fmt.Sprintf("%+d", homeDiff),
"Goal Diff",
fmt.Sprintf("%+d", awayDiff),
homeDiff > awayDiff,
awayDiff > homeDiff,
)
<!-- Recent Form -->
if len(preview.HomeRecentGames) > 0 || len(preview.AwayRecentGames) > 0 {
<div class="flex items-center py-3 border-b border-surface1 last:border-b-0">
<div class="flex-1 flex justify-end pr-4">
<div class="flex items-center gap-1">
for _, g := range preview.HomeRecentGames {
@gameOutcomeIcon(g)
}
</div>
</div>
<div class="w-28 sm:w-36 text-center shrink-0">
<span class="text-xs font-semibold text-subtext0 uppercase tracking-wider">Form</span>
</div>
<div class="flex-1 flex pl-4">
<div class="flex items-center gap-1">
for _, g := range preview.AwayRecentGames {
@gameOutcomeIcon(g)
}
</div>
</div>
</div>
}
</div>
</div>
</div>
}

View File

@@ -0,0 +1,319 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
import "fmt"
import "sort"
// seriesMatchPreviewTab renders the full Match Preview tab for upcoming series.
// Shows team standings comparison, recent form, and full rosters side-by-side.
templ seriesMatchPreviewTab(
series *db.PlayoffSeries,
rosters map[string][]*db.PlayerWithPlayStatus,
preview *db.MatchPreviewData,
) {
<div class="space-y-6">
<!-- Team Comparison Header -->
if preview != nil {
@seriesPreviewHeader(series, preview)
}
<!-- Form Guide (Last 5 Games) -->
if preview != nil {
@seriesPreviewFormGuide(series, preview)
}
<!-- Team Rosters -->
@seriesPreviewRosters(series, rosters)
</div>
}
// seriesPreviewHeader renders the broadcast-style team comparison with standings.
templ seriesPreviewHeader(series *db.PlayoffSeries, preview *db.MatchPreviewData) {
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
<h2 class="text-lg font-bold text-text">Team Comparison</h2>
</div>
<div class="p-6">
<!-- Team Names and VS -->
<div class="flex items-center justify-center gap-4 sm:gap-8 mb-8">
<!-- Team 1 -->
<div class="flex flex-col items-center text-center flex-1">
if series.Team1 != nil && series.Team1.Color != "" {
<div
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
></div>
}
<h3 class="text-xl sm:text-2xl font-bold text-text">
if series.Team1 != nil {
@links.TeamNameLinkInSeason(series.Team1, series.Bracket.Season, series.Bracket.League)
} else {
TBD
}
</h3>
if series.Team1 != nil {
<span class="text-subtext0 text-sm font-mono mt-1">{ series.Team1.ShortName }</span>
}
</div>
<!-- VS Divider -->
<div class="flex flex-col items-center shrink-0">
<span class="text-3xl sm:text-4xl font-bold text-subtext0">VS</span>
</div>
<!-- Team 2 -->
<div class="flex flex-col items-center text-center flex-1">
if series.Team2 != nil && series.Team2.Color != "" {
<div
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
></div>
}
<h3 class="text-xl sm:text-2xl font-bold text-text">
if series.Team2 != nil {
@links.TeamNameLinkInSeason(series.Team2, series.Bracket.Season, series.Bracket.League)
} else {
TBD
}
</h3>
if series.Team2 != nil {
<span class="text-subtext0 text-sm font-mono mt-1">{ series.Team2.ShortName }</span>
}
</div>
</div>
<!-- Stats Comparison Grid -->
{{
homePos := ordinal(preview.HomePosition)
awayPos := ordinal(preview.AwayPosition)
if preview.HomePosition == 0 {
homePos = "N/A"
}
if preview.AwayPosition == 0 {
awayPos = "N/A"
}
}}
<div class="space-y-0">
@previewStatRow(
homePos,
"Position",
awayPos,
preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition,
preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition,
)
@previewStatRow(
fmt.Sprint(preview.HomeRecord.Points),
"Points",
fmt.Sprint(preview.AwayRecord.Points),
preview.HomeRecord.Points > preview.AwayRecord.Points,
preview.AwayRecord.Points > preview.HomeRecord.Points,
)
@previewStatRow(
fmt.Sprint(preview.HomeRecord.Played),
"Played",
fmt.Sprint(preview.AwayRecord.Played),
false,
false,
)
@previewStatRow(
fmt.Sprint(preview.HomeRecord.Wins),
"Wins",
fmt.Sprint(preview.AwayRecord.Wins),
preview.HomeRecord.Wins > preview.AwayRecord.Wins,
preview.AwayRecord.Wins > preview.HomeRecord.Wins,
)
@previewStatRow(
fmt.Sprint(preview.HomeRecord.OvertimeWins),
"OT Wins",
fmt.Sprint(preview.AwayRecord.OvertimeWins),
preview.HomeRecord.OvertimeWins > preview.AwayRecord.OvertimeWins,
preview.AwayRecord.OvertimeWins > preview.HomeRecord.OvertimeWins,
)
@previewStatRow(
fmt.Sprint(preview.HomeRecord.OvertimeLosses),
"OT Losses",
fmt.Sprint(preview.AwayRecord.OvertimeLosses),
preview.HomeRecord.OvertimeLosses < preview.AwayRecord.OvertimeLosses,
preview.AwayRecord.OvertimeLosses < preview.HomeRecord.OvertimeLosses,
)
@previewStatRow(
fmt.Sprint(preview.HomeRecord.Losses),
"Losses",
fmt.Sprint(preview.AwayRecord.Losses),
preview.HomeRecord.Losses < preview.AwayRecord.Losses,
preview.AwayRecord.Losses < preview.HomeRecord.Losses,
)
@previewStatRow(
fmt.Sprint(preview.HomeRecord.GoalsFor),
"Goals For",
fmt.Sprint(preview.AwayRecord.GoalsFor),
preview.HomeRecord.GoalsFor > preview.AwayRecord.GoalsFor,
preview.AwayRecord.GoalsFor > preview.HomeRecord.GoalsFor,
)
@previewStatRow(
fmt.Sprint(preview.HomeRecord.GoalsAgainst),
"Goals Against",
fmt.Sprint(preview.AwayRecord.GoalsAgainst),
preview.HomeRecord.GoalsAgainst < preview.AwayRecord.GoalsAgainst,
preview.AwayRecord.GoalsAgainst < preview.HomeRecord.GoalsAgainst,
)
{{
homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst
awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst
}}
@previewStatRow(
fmt.Sprintf("%+d", homeDiff),
"Goal Diff",
fmt.Sprintf("%+d", awayDiff),
homeDiff > awayDiff,
awayDiff > homeDiff,
)
</div>
</div>
</div>
}
// seriesPreviewFormGuide renders recent form for each team.
templ seriesPreviewFormGuide(series *db.PlayoffSeries, preview *db.MatchPreviewData) {
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
<h2 class="text-lg font-bold text-text">Recent Form</h2>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Team 1 Form -->
<div>
<div class="flex items-center gap-2 mb-4">
if series.Team1 != nil && series.Team1.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
></span>
}
<h3 class="text-md font-bold text-text">{ seriesTeamName(series.Team1) }</h3>
</div>
if len(preview.HomeRecentGames) == 0 {
<p class="text-subtext1 text-sm">No recent matches played</p>
} else {
<div class="flex items-center gap-1.5 mb-4">
for _, g := range preview.HomeRecentGames {
@gameOutcomeIcon(g)
}
</div>
<div class="space-y-1.5">
for i := len(preview.HomeRecentGames) - 1; i >= 0; i-- {
@recentGameRow(preview.HomeRecentGames[i])
}
</div>
}
</div>
<!-- Team 2 Form -->
<div>
<div class="flex items-center gap-2 mb-4">
if series.Team2 != nil && series.Team2.Color != "" {
<span
class="w-3 h-3 rounded-full shrink-0"
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
></span>
}
<h3 class="text-md font-bold text-text">{ seriesTeamName(series.Team2) }</h3>
</div>
if len(preview.AwayRecentGames) == 0 {
<p class="text-subtext1 text-sm">No recent matches played</p>
} else {
<div class="flex items-center gap-1.5 mb-4">
for _, g := range preview.AwayRecentGames {
@gameOutcomeIcon(g)
}
</div>
<div class="space-y-1.5">
for i := len(preview.AwayRecentGames) - 1; i >= 0; i-- {
@recentGameRow(preview.AwayRecentGames[i])
}
</div>
}
</div>
</div>
</div>
</div>
}
// seriesPreviewRosters renders team rosters side-by-side.
templ seriesPreviewRosters(series *db.PlayoffSeries, rosters map[string][]*db.PlayerWithPlayStatus) {
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
<h2 class="text-lg font-bold text-text">Team Rosters</h2>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
if series.Team1 != nil {
@seriesPreviewRosterColumn(series.Team1, rosters["team1"], series.Bracket.Season, series.Bracket.League)
}
if series.Team2 != nil {
@seriesPreviewRosterColumn(series.Team2, rosters["team2"], series.Bracket.Season, series.Bracket.League)
}
</div>
</div>
</div>
}
templ seriesPreviewRosterColumn(
team *db.Team,
players []*db.PlayerWithPlayStatus,
season *db.Season,
league *db.League,
) {
{{
var managers []*db.PlayerWithPlayStatus
var roster []*db.PlayerWithPlayStatus
for _, p := range players {
if p.IsManager {
managers = append(managers, p)
} else {
roster = append(roster, p)
}
}
sort.Slice(roster, func(i, j int) bool {
return roster[i].Player.DisplayName() < roster[j].Player.DisplayName()
})
}}
<div>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
if team.Color != "" {
<span
class="w-3.5 h-3.5 rounded-full shrink-0 border border-surface1"
style={ "background-color: " + templ.SafeCSS(team.Color) }
></span>
}
<h3 class="text-md font-bold">
@links.TeamNameLinkInSeason(team, season, league)
</h3>
</div>
<span class="text-xs text-subtext0">
{ fmt.Sprint(len(players)) } players
</span>
</div>
if len(players) == 0 {
<p class="text-subtext1 text-sm text-center py-4">No players on roster.</p>
} else {
<div class="space-y-1">
for _, p := range managers {
<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface0 border border-surface1">
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium shrink-0">
&#9733;
</span>
<span class="text-sm font-medium">
@links.PlayerLink(p.Player)
</span>
</div>
}
for _, p := range roster {
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface0 transition">
<span class="text-sm">
@links.PlayerLink(p.Player)
</span>
</div>
}
</div>
}
</div>
}

View File

@@ -0,0 +1,504 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "fmt"
// ==================== Schedule Tab ====================
templ seriesScheduleTab(
series *db.PlayoffSeries,
currentSchedule *db.PlayoffSeriesSchedule,
history []*db.PlayoffSeriesSchedule,
canSchedule bool,
canManage bool,
userTeamID int,
) {
<div class="space-y-6">
@seriesScheduleStatus(series, currentSchedule, canSchedule, canManage, userTeamID)
@seriesScheduleActions(series, currentSchedule, canSchedule, canManage, userTeamID)
@seriesScheduleHistory(series, history)
</div>
}
templ seriesScheduleStatus(
series *db.PlayoffSeries,
current *db.PlayoffSeriesSchedule,
canSchedule bool,
canManage bool,
userTeamID int,
) {
{{
bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil
}}
<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">Schedule Status</h2>
</div>
<div class="p-6">
if !bothTeamsAssigned {
<div class="text-center py-4">
<div class="text-4xl mb-3">⏳</div>
<p class="text-lg text-text font-medium">Waiting for Teams</p>
<p class="text-sm text-subtext1 mt-1">
Both teams must be determined before scheduling can begin.
</p>
</div>
} else if current == nil {
<div class="text-center py-4">
<div class="text-4xl mb-3">📅</div>
<p class="text-lg text-text font-medium">No time scheduled</p>
<p class="text-sm text-subtext1 mt-1">
if canSchedule {
Use the form to propose a time for this series.
} else {
A team manager needs to propose a time for this series.
}
</p>
</div>
} else if current.Status == db.ScheduleStatusPending && current.ScheduledTime != nil {
<div class="text-center py-4">
<div class="text-4xl mb-3">⏳</div>
<p class="text-lg text-text font-medium">
Proposed:
@localtime(current.ScheduledTime, "datetime")
</p>
<p class="text-sm text-subtext1 mt-1">
Proposed by
<span class="text-text font-medium">{ current.ProposedBy.Name }</span>
— awaiting response from the other team
</p>
if canSchedule && userTeamID != current.ProposedByTeamID {
<div class="flex justify-center gap-3 mt-4">
<form
hx-post={ fmt.Sprintf("/series/%d/schedule/%d/accept", series.ID, current.ID) }
hx-swap="none"
>
<button
type="submit"
class="px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Accept
</button>
</form>
<form
hx-post={ fmt.Sprintf("/series/%d/schedule/%d/reject", series.ID, current.ID) }
hx-swap="none"
>
<button
type="submit"
class="px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Reject
</button>
</form>
</div>
}
if canSchedule && userTeamID == current.ProposedByTeamID {
<div class="flex justify-center mt-4">
<button
type="button"
@click={ "window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Withdraw Proposal', message: 'Are you sure you want to withdraw your proposed time?', action: () => htmx.ajax('POST', '/series/" + fmt.Sprint(series.ID) + "/schedule/" + fmt.Sprint(current.ID) + "/withdraw', { swap: 'none' }) } }))" }
class="px-4 py-2 bg-surface1 hover:bg-surface2 text-text rounded-lg
font-medium transition hover:cursor-pointer"
>
Withdraw Proposal
</button>
</div>
}
</div>
} else if current.Status == db.ScheduleStatusAccepted {
<div class="text-center py-4">
<div class="text-4xl mb-3">✅</div>
<p class="text-lg text-green font-medium">
Confirmed:
@localtime(current.ScheduledTime, "datetime")
</p>
<p class="text-sm text-subtext1 mt-1">
Both teams have agreed on this time.
</p>
</div>
} else if current.Status == db.ScheduleStatusRejected {
<div class="text-center py-4">
<div class="text-4xl mb-3">❌</div>
<p class="text-lg text-red font-medium">Proposal Rejected</p>
<p class="text-sm text-subtext1 mt-1">
The proposed time was rejected. A new time needs to be proposed.
</p>
</div>
} else if current.Status == db.ScheduleStatusCancelled {
<div class="text-center py-4">
<div class="text-4xl mb-3">🚫</div>
<p class="text-lg text-red font-medium">Schedule Cancelled</p>
if current.RescheduleReason != nil {
<p class="text-sm text-subtext1 mt-1">
{ *current.RescheduleReason }
</p>
}
</div>
} else if current.Status == db.ScheduleStatusRescheduled {
<div class="text-center py-4">
<div class="text-4xl mb-3">🔄</div>
<p class="text-lg text-yellow font-medium">Rescheduled</p>
if current.RescheduleReason != nil {
<p class="text-sm text-subtext1 mt-1">
Reason: { *current.RescheduleReason }
</p>
}
<p class="text-sm text-subtext1 mt-1">
A new time needs to be proposed.
</p>
</div>
} else if current.Status == db.ScheduleStatusPostponed {
<div class="text-center py-4">
<div class="text-4xl mb-3">⏸️</div>
<p class="text-lg text-peach font-medium">Postponed</p>
if current.RescheduleReason != nil {
<p class="text-sm text-subtext1 mt-1">
Reason: { *current.RescheduleReason }
</p>
}
<p class="text-sm text-subtext1 mt-1">
A new time needs to be proposed.
</p>
</div>
} else if current.Status == db.ScheduleStatusWithdrawn {
<div class="text-center py-4">
<div class="text-4xl mb-3">↩️</div>
<p class="text-lg text-subtext0 font-medium">Proposal Withdrawn</p>
<p class="text-sm text-subtext1 mt-1">
The proposed time was withdrawn. A new time needs to be proposed.
</p>
</div>
}
</div>
</div>
}
templ seriesScheduleActions(
series *db.PlayoffSeries,
current *db.PlayoffSeriesSchedule,
canSchedule bool,
canManage bool,
userTeamID int,
) {
{{
bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil
showPropose := false
showReschedule := false
showPostpone := false
showCancel := false
if bothTeamsAssigned && canSchedule {
if current == nil {
showPropose = true
} else if current.Status == db.ScheduleStatusRejected {
showPropose = true
} else if current.Status == db.ScheduleStatusRescheduled {
showPropose = true
} else if current.Status == db.ScheduleStatusPostponed {
showPropose = true
} else if current.Status == db.ScheduleStatusWithdrawn {
showPropose = true
} else if current.Status == db.ScheduleStatusAccepted {
showReschedule = true
showPostpone = true
}
}
if bothTeamsAssigned && canManage && current != nil && !current.Status.IsTerminal() {
showCancel = true
}
}}
if showPropose || showReschedule || showPostpone || showCancel {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Propose Time -->
if showPropose {
<div class="bg-mantle border border-surface1 rounded-lg">
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
<h3 class="text-md font-bold text-text">Propose Time</h3>
</div>
<div class="p-4">
<form
hx-post={ fmt.Sprintf("/series/%d/schedule", series.ID) }
hx-swap="none"
class="space-y-4"
>
<div>
<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"
required
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
focus:border-blue focus:outline-none"
/>
</div>
<button
type="submit"
class="w-full px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Propose Time
</button>
</form>
</div>
</div>
}
<!-- Reschedule -->
if showReschedule {
<div class="bg-mantle border border-surface1 rounded-lg">
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
<h3 class="text-md font-bold text-text">Reschedule</h3>
</div>
<div class="p-4">
<form
hx-post={ fmt.Sprintf("/series/%d/schedule/reschedule", series.ID) }
hx-swap="none"
class="space-y-4"
>
<div>
<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"
required
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
focus:border-blue focus:outline-none"
/>
</div>
<div>
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
@seriesRescheduleReasonSelect(series)
</div>
<button
type="submit"
class="w-full px-4 py-2 bg-yellow hover:bg-yellow/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Reschedule
</button>
</form>
</div>
</div>
}
<!-- Postpone -->
if showPostpone {
<div class="bg-mantle border border-surface1 rounded-lg">
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
<h3 class="text-md font-bold text-text">Postpone</h3>
</div>
<div class="p-4">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
@seriesRescheduleReasonSelect(series)
</div>
<button
type="button"
@click={ fmt.Sprintf("if (!$el.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Postpone Series', message: 'Are you sure you want to postpone this series? The current schedule will be cleared.', action: () => htmx.ajax('POST', '/series/%d/schedule/postpone', { swap: 'none', values: { reschedule_reason: $el.parentElement.querySelector('select').value } }) } }))", series.ID) }
class="w-full px-4 py-2 bg-peach hover:bg-peach/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Postpone Series
</button>
</div>
</div>
</div>
}
<!-- Cancel (moderator only) -->
if showCancel {
<div class="bg-mantle border border-red/30 rounded-lg">
<div class="bg-red/10 border-b border-red/30 px-4 py-3 rounded-t-lg">
<h3 class="text-md font-bold text-red">Cancel Schedule</h3>
</div>
<div class="p-4">
<p class="text-xs text-red/80 mb-3 font-medium">
This action will cancel the current series schedule.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
<input
type="text"
name="reschedule_reason"
placeholder="Enter reason..."
required
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
focus:border-blue focus:outline-none"
/>
</div>
<button
type="button"
@click={ fmt.Sprintf("const reason = $el.parentElement.querySelector('input[name=reschedule_reason]').value; if (!reason) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Cancel Schedule', message: 'Are you sure you want to cancel this schedule?', action: () => htmx.ajax('POST', '/series/%d/schedule/cancel', { swap: 'none', values: { reschedule_reason: reason } }) } }))", series.ID) }
class="w-full px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Cancel Schedule
</button>
</div>
</div>
</div>
}
</div>
} else {
if !canSchedule && !canManage {
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
<p class="text-subtext1 text-sm">
Only team managers can manage series scheduling.
</p>
</div>
}
}
}
templ seriesRescheduleReasonSelect(series *db.PlayoffSeries) {
{{
team1Name := seriesTeamName(series.Team1)
team2Name := seriesTeamName(series.Team2)
}}
<select
name="reschedule_reason"
required
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
focus:border-blue focus:outline-none hover:cursor-pointer"
>
<option value="" disabled selected>Select a reason</option>
<option value="Mutually Agreed">Mutually Agreed</option>
<option value={ fmt.Sprintf("%s Unavailable", team1Name) }>
{ team1Name } Unavailable
</option>
<option value={ fmt.Sprintf("%s Unavailable", team2Name) }>
{ team2Name } Unavailable
</option>
<option value={ fmt.Sprintf("%s No-show", team1Name) }>
{ team1Name } No-show
</option>
<option value={ fmt.Sprintf("%s No-show", team2Name) }>
{ team2Name } No-show
</option>
</select>
}
templ seriesScheduleHistory(series *db.PlayoffSeries, history []*db.PlayoffSeriesSchedule) {
<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">Schedule History</h2>
</div>
<div class="p-4">
if len(history) == 0 {
<p class="text-subtext1 text-sm text-center py-4">No scheduling activity yet.</p>
} else {
<div class="space-y-3">
for i := len(history) - 1; i >= 0; i-- {
@seriesScheduleHistoryItem(history[i], i == len(history)-1)
}
</div>
}
</div>
</div>
}
templ seriesScheduleHistoryItem(schedule *db.PlayoffSeriesSchedule, isCurrent bool) {
{{
statusColor := "text-subtext0"
statusBg := "bg-surface1"
statusLabel := string(schedule.Status)
switch schedule.Status {
case db.ScheduleStatusPending:
statusColor = "text-blue"
statusBg = "bg-blue/20"
statusLabel = "Pending"
case db.ScheduleStatusAccepted:
statusColor = "text-green"
statusBg = "bg-green/20"
statusLabel = "Accepted"
case db.ScheduleStatusRejected:
statusColor = "text-red"
statusBg = "bg-red/20"
statusLabel = "Rejected"
case db.ScheduleStatusRescheduled:
statusColor = "text-yellow"
statusBg = "bg-yellow/20"
statusLabel = "Rescheduled"
case db.ScheduleStatusPostponed:
statusColor = "text-peach"
statusBg = "bg-peach/20"
statusLabel = "Postponed"
case db.ScheduleStatusCancelled:
statusColor = "text-red"
statusBg = "bg-red/20"
statusLabel = "Cancelled"
case db.ScheduleStatusWithdrawn:
statusColor = "text-subtext0"
statusBg = "bg-surface1"
statusLabel = "Withdrawn"
}
}}
<div class={ "border rounded-lg p-3", templ.KV("border-surface1", !isCurrent), templ.KV("border-blue/30 bg-blue/5", isCurrent) }>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
if isCurrent {
<span class="text-xs px-1.5 py-0.5 bg-blue/20 text-blue rounded font-medium">
CURRENT
</span>
}
<span class={ "text-xs px-2 py-0.5 rounded font-medium", statusBg, statusColor }>
{ statusLabel }
</span>
</div>
<span class="text-xs text-subtext1">
@localtimeUnix(schedule.CreatedAt, "histdate")
</span>
</div>
<div class="space-y-1">
<div class="flex items-center gap-2 text-sm">
<span class="text-subtext0">Proposed by:</span>
<span class="text-text font-medium">{ schedule.ProposedBy.Name }</span>
</div>
if schedule.ScheduledTime != nil {
<div class="flex items-center gap-2 text-sm">
<span class="text-subtext0">Time:</span>
<span class="text-text">
@localtime(schedule.ScheduledTime, "datetime")
</span>
</div>
} else {
<div class="flex items-center gap-2 text-sm">
<span class="text-subtext0">Time:</span>
<span class="text-subtext1 italic">No time set</span>
</div>
}
if schedule.RescheduleReason != nil {
<div class="flex items-center gap-2 text-sm">
<span class="text-subtext0">Reason:</span>
<span class="text-subtext1">{ *schedule.RescheduleReason }</span>
</div>
}
</div>
</div>
}