Compare commits

..

42 Commits

Author SHA1 Message Date
c6ed0174ec Merge branch 'finals' into development 2026-03-15 19:10:48 +11:00
db24283037 added finals forfeits 2026-03-15 19:10:32 +11:00
ccc1b0505f Merge branch 'finals' into development 2026-03-15 17:43:46 +11:00
b91725f53f fixed some button issues 2026-03-15 17:43:39 +11:00
82a71d6490 fixed forfeit button not working on page load 2026-03-15 17:43:39 +11:00
957ca4aec6 fixed invalid_grant resulting in internal server error 2026-03-15 17:43:39 +11:00
f98b7b2d88 finals stats moved out of regular season stats 2026-03-15 17:43:39 +11:00
ea2f82ba9e added finals log uploads 2026-03-15 17:43:39 +11:00
fc7f5665e7 series overview added 2026-03-15 17:43:39 +11:00
3666870111 playoff visual fixes 2026-03-15 17:43:39 +11:00
723a213be3 finals generation added 2026-03-15 17:43:39 +11:00
547a75e082 Merge branch 'staging' 2026-03-07 19:33:00 +11:00
93c517de47 Merge branch 'homepage' into development 2026-03-07 14:34:04 +11:00
8b33d57d59 added kofi link 2026-03-07 14:33:13 +11:00
3e0ad128a2 removed placeholders from footer 2026-03-07 14:23:05 +11:00
d530b41520 home page added 2026-03-07 14:18:06 +11:00
d6dd2b06c8 Merge branch 'fixes' into development 2026-03-07 13:24:51 +11:00
eaaa62de43 added staging banner 2026-03-07 13:24:36 +11:00
a1ee591e6f stats page layout tweaks 2026-03-07 13:13:09 +11:00
c2b047723f navbar tweaks 2026-03-07 13:06:58 +11:00
e27d953c2a scroll fixes 2026-03-07 13:01:08 +11:00
f8031f0523 fixed build recipe not generating templ files 2026-03-07 12:36:15 +11:00
d77cfa1c15 fixed fixture page to use htmx tab pattern 2026-03-07 12:34:49 +11:00
ecae24e73e changed registration to have blank username on first load 2026-03-07 12:22:11 +11:00
d0c426216d removed theme switcher, always uses dark theme 2026-03-07 11:53:49 +11:00
62aa9ee629 Merge branch 'stats' into development 2026-03-07 11:47:59 +11:00
8819977ebb added match preview and analysis 2026-03-06 22:08:41 +11:00
4783e21477 fixed stat sorting 2026-03-06 21:40:18 +11:00
1c03581795 added league stats 2026-03-06 21:37:02 +11:00
f81ae78b3b added stat leaderboards 2026-03-06 21:25:05 +11:00
a3abf1b1cd removed "registered by" field for free agents 2026-03-06 21:05:55 +11:00
9884793bca added better links to teams and players 2026-03-06 21:03:51 +11:00
e99f10d0f4 added player stats to profile 2026-03-06 20:48:21 +11:00
71181c43e9 player profile added 2026-03-06 19:51:27 +11:00
fc219a044c updated team stats 2026-03-06 19:06:29 +11:00
c53fbac281 added team overview 2026-03-06 18:53:58 +11:00
baa15f03fa added ot stats to team view 2026-03-06 18:29:23 +11:00
eb9fd64935 added periods played 2026-03-06 18:23:37 +11:00
2ef592bf79 Merge branch 'forfeits' into development 2026-03-06 18:01:39 +11:00
1b99403e10 forfeits added 2026-03-05 22:22:32 +11:00
af09c6db2c Merge branch 'development' into staging 2026-03-05 18:57:37 +11:00
ca804ebecc playernames now added on creation 2026-03-05 18:56:48 +11:00
7 changed files with 528 additions and 31 deletions

View File

@@ -0,0 +1,71 @@
package migrations
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, conn *bun.DB) error {
// Add is_forfeit column
_, err := conn.NewAddColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("is_forfeit BOOLEAN NOT NULL DEFAULT false").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Add forfeit_team_id column
_, err = conn.NewAddColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("forfeit_team_id INTEGER REFERENCES teams(id)").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Add forfeit_reason column
_, err = conn.NewAddColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("forfeit_reason VARCHAR").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
_, err := conn.NewDropColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("forfeit_reason").
Exec(ctx)
if err != nil {
return err
}
_, err = conn.NewDropColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("forfeit_team_id").
Exec(ctx)
if err != nil {
return err
}
_, err = conn.NewDropColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("is_forfeit").
Exec(ctx)
return err
},
)
}

View File

@@ -81,12 +81,18 @@ type PlayoffSeries struct {
LoserNextSlot *string `bun:"loser_next_slot"` // "team1" or "team2" in loser's next series
CreatedAt int64 `bun:",notnull"`
Bracket *PlayoffBracket `bun:"rel:belongs-to,join:bracket_id=id"`
Team1 *Team `bun:"rel:belongs-to,join:team1_id=id"`
Team2 *Team `bun:"rel:belongs-to,join:team2_id=id"`
Winner *Team `bun:"rel:belongs-to,join:winner_team_id=id"`
Loser *Team `bun:"rel:belongs-to,join:loser_team_id=id"`
Matches []*PlayoffMatch `bun:"rel:has-many,join:id=series_id"`
// Forfeit-related fields
IsForfeit bool `bun:"is_forfeit,default:false"`
ForfeitTeamID *int `bun:"forfeit_team_id"` // Which team forfeited
ForfeitReason *string `bun:"forfeit_reason"` // Admin-provided reason
Bracket *PlayoffBracket `bun:"rel:belongs-to,join:bracket_id=id"`
Team1 *Team `bun:"rel:belongs-to,join:team1_id=id"`
Team2 *Team `bun:"rel:belongs-to,join:team2_id=id"`
Winner *Team `bun:"rel:belongs-to,join:winner_team_id=id"`
Loser *Team `bun:"rel:belongs-to,join:loser_team_id=id"`
ForfeitTeam *Team `bun:"rel:belongs-to,join:forfeit_team_id=id"`
Matches []*PlayoffMatch `bun:"rel:has-many,join:id=series_id"`
}
// PlayoffMatch represents a single game within a series
@@ -344,6 +350,7 @@ func GetPlayoffSeriesByID(
Relation("Team2").
Relation("Winner").
Relation("Loser").
Relation("ForfeitTeam").
Relation("Matches", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Order("pm.match_number ASC")
}).

View File

@@ -329,6 +329,149 @@ func DeleteSeriesResults(
return nil
}
// ForfeitSeries forfeits a playoff series. The forfeiting team loses and the opponent
// is declared the winner and advances through the bracket. Any existing match results
// and fixtures are discarded. The series score (Team1Wins/Team2Wins) is left as-is.
func ForfeitSeries(
ctx context.Context,
tx bun.Tx,
seriesID int,
forfeitTeamID int,
reason string,
audit *AuditMeta,
) error {
series, err := GetPlayoffSeriesByID(ctx, tx, seriesID)
if err != nil {
return errors.Wrap(err, "GetPlayoffSeriesByID")
}
if series == nil {
return BadRequest("series not found")
}
if series.Status == SeriesStatusCompleted {
return BadRequest("series is already completed")
}
if series.Status == SeriesStatusBye {
return BadRequest("cannot forfeit a bye series")
}
if series.Team1ID == nil || series.Team2ID == nil {
return BadRequest("both teams must be assigned to forfeit a series")
}
// Validate forfeit team is one of the teams in the series
if forfeitTeamID != *series.Team1ID && forfeitTeamID != *series.Team2ID {
return BadRequest("forfeit team must be one of the teams in the series")
}
// Determine winner and loser
var winnerTeamID int
if forfeitTeamID == *series.Team1ID {
winnerTeamID = *series.Team2ID
} else {
winnerTeamID = *series.Team1ID
}
// Discard all existing match results and fixtures
for _, match := range series.Matches {
if match.FixtureID == nil {
continue
}
result, err := GetFixtureResult(ctx, tx, *match.FixtureID)
if err != nil {
return errors.Wrap(err, "GetFixtureResult")
}
if result != nil {
// Delete result (CASCADE deletes player stats)
err = DeleteByID[FixtureResult](tx, result.ID).
WithAudit(audit, &AuditInfo{
Action: "fixture_results.discard_for_forfeit",
ResourceType: "fixture_result",
ResourceID: result.ID,
Details: map[string]any{
"fixture_id": *match.FixtureID,
"series_id": seriesID,
},
}).Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteByID fixture_result")
}
}
// Delete the fixture
err = DeleteByID[Fixture](tx, *match.FixtureID).
WithAudit(audit, &AuditInfo{
Action: "playoff_fixture.delete_for_forfeit",
ResourceType: "fixture",
ResourceID: *match.FixtureID,
Details: map[string]any{
"series_id": seriesID,
},
}).Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteByID fixture")
}
// Clear fixture ID from match
match.FixtureID = nil
match.Status = "pending"
err = UpdateByID(tx, match.ID, match).
Column("fixture_id", "status").
Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID playoff_match")
}
}
// Update series with forfeit info
var reasonPtr *string
if reason != "" {
reasonPtr = &reason
}
series.Status = SeriesStatusCompleted
series.IsForfeit = true
series.ForfeitTeamID = &forfeitTeamID
series.ForfeitReason = reasonPtr
series.WinnerTeamID = &winnerTeamID
series.LoserTeamID = &forfeitTeamID
err = UpdateByID(tx, series.ID, series).
Column("status", "is_forfeit", "forfeit_team_id", "forfeit_reason", "winner_team_id", "loser_team_id").
WithAudit(audit, &AuditInfo{
Action: "playoff_series.forfeit",
ResourceType: "playoff_series",
ResourceID: series.ID,
Details: map[string]any{
"forfeit_team_id": forfeitTeamID,
"winner_team_id": winnerTeamID,
"reason": reason,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID series forfeit")
}
// Advance winner to next series
if series.WinnerNextID != nil && series.WinnerNextSlot != nil {
err = advanceTeamToSeries(ctx, tx, *series.WinnerNextID, *series.WinnerNextSlot, winnerTeamID)
if err != nil {
return errors.Wrap(err, "advanceTeamToSeries winner")
}
}
// Advance loser to next series (e.g. lower bracket)
if series.LoserNextID != nil && series.LoserNextSlot != nil {
err = advanceTeamToSeries(ctx, tx, *series.LoserNextID, *series.LoserNextSlot, forfeitTeamID)
if err != nil {
return errors.Wrap(err, "advanceTeamToSeries loser")
}
}
return nil
}
// HasPendingSeriesResults checks if a series has any pending (non-finalized) results.
func HasPendingSeriesResults(ctx context.Context, tx bun.Tx, seriesID int) (bool, error) {
series, err := GetPlayoffSeriesByID(ctx, tx, seriesID)

View File

@@ -0,0 +1,94 @@
package handlers
import (
"context"
"net/http"
"strconv"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/validation"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// ForfeitSeries handles POST /series/{series_id}/forfeit
// Forfeits a playoff series. The forfeiting team loses and the opponent wins
// and advances through the bracket. Requires playoffs.manage permission.
func ForfeitSeries(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid series ID", err)
return
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
forfeitTeamStr := getter.String("forfeit_team").TrimSpace().Required().Value
forfeitReason := getter.String("forfeit_reason").TrimSpace().Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
// Validate forfeit_team is "team1" or "team2"
if forfeitTeamStr != "team1" && forfeitTeamStr != "team2" {
notify.Warn(s, w, r, "Invalid Team", "Please select which team is forfeiting.", nil)
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetPlayoffSeriesByID"))
return false, nil
}
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
}
if series == nil {
respond.NotFound(w, errors.New("series not found"))
return false, nil
}
// Resolve the forfeit team ID
var forfeitTeamID int
if forfeitTeamStr == "team1" {
if series.Team1ID == nil {
notify.Warn(s, w, r, "No Team", "Team 1 is not assigned.", nil)
return false, nil
}
forfeitTeamID = *series.Team1ID
} else {
if series.Team2ID == nil {
notify.Warn(s, w, r, "No Team", "Team 2 is not assigned.", nil)
return false, nil
}
forfeitTeamID = *series.Team2ID
}
err = db.ForfeitSeries(ctx, tx, seriesID, forfeitTeamID, forfeitReason, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Forfeit", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.ForfeitSeries")
}
return true, nil
}); !ok {
return
}
notify.SuccessWithDelay(s, w, r, "Series Forfeited", "The series has been forfeited and the opponent advances.", nil)
respond.HXRedirect(w, "/series/%d", seriesID)
})
}

View File

@@ -417,6 +417,12 @@ func addRoutes(
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesDiscardResults(s, conn)),
},
// Series forfeit route
{
Path: "/series/{series_id}/forfeit",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.ForfeitSeries(s, conn)),
},
}
playerRoutes := []hws.Route{

View File

@@ -201,6 +201,7 @@ 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)
isForfeit := series.IsForfeit
}}
<div
data-series={ fmt.Sprint(series.SeriesNumber) }
@@ -209,7 +210,8 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
}
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-red/50", isForfeit),
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress && !isForfeit),
templ.KV("hover:bg-surface1 hover:cursor-pointer transition", hasTeams) }
>
<!-- Series Header -->
@@ -218,17 +220,24 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
<span class="text-xs font-semibold text-subtext0">{ series.Label }</span>
@seriesFormatBadge(series.MatchesToWin)
</div>
@seriesStatusBadge(series.Status)
<div class="flex items-center gap-1">
if isForfeit {
<span class="px-1.5 py-0.5 bg-red/20 text-red rounded text-xs font-bold">
FF
</span>
}
@seriesStatusBadge(series.Status)
</div>
</div>
<!-- Teams -->
<div class="divide-y divide-surface1">
@seriesTeamRow(season, league, series.Team1, series.Team1Seed, series.Team1Wins,
series.WinnerTeamID, series.MatchesToWin)
series.WinnerTeamID, series.ForfeitTeamID, series.MatchesToWin)
@seriesTeamRow(season, league, series.Team2, series.Team2Seed, series.Team2Wins,
series.WinnerTeamID, series.MatchesToWin)
series.WinnerTeamID, series.ForfeitTeamID, series.MatchesToWin)
</div>
<!-- Series Score -->
if series.MatchesToWin > 1 {
if series.MatchesToWin > 1 && !isForfeit {
<div class="bg-mantle px-3 py-1 text-center text-xs text-subtext0 border-t border-surface1">
{ fmt.Sprint(series.Team1Wins) } - { fmt.Sprint(series.Team2Wins) }
</div>
@@ -236,16 +245,21 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
</div>
}
templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *int, wins int, winnerID *int, matchesToWin int) {
templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *int, wins int, winnerID *int, forfeitTeamID *int, matchesToWin int) {
{{
isWinner := false
if team != nil && winnerID != nil {
isWinner = team.ID == *winnerID
}
isForfeiter := false
if team != nil && forfeitTeamID != nil {
isForfeiter = team.ID == *forfeitTeamID
}
isTBD := team == nil
}}
<div class={ "flex items-center justify-between px-3 py-2",
templ.KV("bg-green/5", isWinner) }>
templ.KV("bg-green/5", isWinner && !isForfeiter),
templ.KV("bg-red/5", isForfeiter) }>
<div class="flex items-center gap-2 min-w-0">
if seed != nil {
<span class="text-xs font-mono text-subtext0 w-4 text-right flex-shrink-0">
@@ -260,12 +274,14 @@ templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *i
<div class="truncate">
@links.TeamLinkInSeason(team, season, league)
</div>
if isWinner {
if isForfeiter {
<span class="text-red text-xs font-bold flex-shrink-0">FF</span>
} else if isWinner {
<span class="text-green text-xs flex-shrink-0">✓</span>
}
}
</div>
if matchesToWin > 1 {
if matchesToWin > 1 && forfeitTeamID == nil {
<span class={ "text-sm font-mono flex-shrink-0 ml-2",
templ.KV("text-text", !isWinner),
templ.KV("text-green font-bold", isWinner) }>

View File

@@ -312,30 +312,164 @@ templ seriesUploadPrompt(series *db.PlayoffSeries) {
}
}
}}
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
<div
x-data="{ open: false }"
class="bg-mantle border border-surface1 rounded-lg p-6 text-center"
>
if hasPendingMatches {
<div class="text-4xl mb-3">📋</div>
<p class="text-lg text-text font-medium mb-2">Results Pending Review</p>
<p class="text-sm text-subtext1 mb-4">Uploaded results are waiting to be reviewed and finalized.</p>
<a
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/review", series.ID)) }
class="inline-block px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Review Results
</a>
<div class="flex items-center justify-center gap-3">
<a
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/review", series.ID)) }
class="inline-block px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Review Results
</a>
<button
type="button"
@click="open = true"
class="inline-block px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Forfeit Series
</button>
</div>
} else {
<div class="text-4xl mb-3">📋</div>
<p class="text-lg text-text font-medium mb-2">No Results Uploaded</p>
<p class="text-sm text-subtext1 mb-4">Upload match log files to record the series results.</p>
<a
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/upload", series.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 class="flex items-center justify-center gap-3">
<a
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/upload", series.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>
<button
type="button"
@click="open = true"
class="inline-block px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
font-medium transition hover:cursor-pointer"
>
Forfeit Series
</button>
</div>
}
@seriesForfeitModal(series)
</div>
}
templ seriesForfeitModal(series *db.PlayoffSeries) {
{{
team1Name := seriesTeamName(series.Team1)
team2Name := seriesTeamName(series.Team2)
}}
<div
x-show="open"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true"
>
<!-- Background overlay -->
<div
x-show="open"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-base/75 transition-opacity"
@click="open = false"
></div>
<!-- Modal panel -->
<div class="flex min-h-full items-center justify-center p-4">
<div
x-show="open"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="relative transform overflow-hidden rounded-lg bg-mantle border-2 border-surface1 shadow-xl transition-all sm:w-full sm:max-w-lg"
@click.stop
x-data="{ forfeitTeam: '', forfeitReason: '' }"
>
<form
hx-post={ fmt.Sprintf("/series/%d/forfeit", series.ID) }
hx-swap="none"
>
<div class="bg-mantle px-4 pb-4 pt-5 sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red/10 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-red" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"></path>
</svg>
</div>
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left w-full">
<h3 class="text-lg font-semibold leading-6 text-text">Forfeit Series</h3>
<div class="mt-2">
<p class="text-sm text-subtext0 mb-4">
This will forfeit the entire series. The selected team will lose and the opponent
will be declared the winner and advance. Any existing match results will be discarded.
This action is immediate and cannot be undone.
</p>
<!-- Team Selection -->
<div class="space-y-2">
<label class="text-sm font-medium text-text">Which team is forfeiting?</label>
<select
name="forfeit_team"
x-model="forfeitTeam"
class="w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text
focus:border-red focus:outline-none hover:cursor-pointer"
>
<option value="">Select a team...</option>
<option value="team1">{ team1Name }</option>
<option value="team2">{ team2Name }</option>
</select>
</div>
<!-- Reason -->
<div class="mt-4 space-y-2">
<label class="text-sm font-medium text-text">Reason (optional)</label>
<textarea
name="forfeit_reason"
x-model="forfeitReason"
placeholder="Provide a reason for the forfeit..."
class="w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text
focus:border-blue focus:outline-none resize-none"
rows="3"
></textarea>
</div>
</div>
</div>
</div>
</div>
<div class="bg-surface0 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
<button
type="submit"
class="inline-flex w-full justify-center rounded-lg bg-red px-4 py-2 text-sm font-semibold text-mantle shadow-sm hover:bg-red/75 hover:cursor-pointer transition sm:w-auto"
:disabled="forfeitTeam === ''"
:class="forfeitTeam === '' && 'opacity-50 cursor-not-allowed'"
>
Confirm Forfeit
</button>
<button
type="button"
@click="open = false"
class="mt-3 inline-flex w-full justify-center rounded-lg bg-surface1 px-4 py-2 text-sm font-semibold text-text shadow-sm hover:bg-surface2 hover:cursor-pointer transition sm:mt-0 sm:w-auto"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
}
@@ -345,11 +479,21 @@ templ seriesScoreDisplay(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
isBye := series.Status == db.SeriesStatusBye
isForfeit := series.IsForfeit
forfeitTeamName := ""
if isForfeit && series.ForfeitTeam != nil {
forfeitTeamName = series.ForfeitTeam.Name
}
}}
<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">
if isForfeit {
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
Forfeited
</span>
}
@seriesStatusBadge(series.Status)
@seriesFormatBadge(series.MatchesToWin)
</div>
@@ -386,7 +530,11 @@ templ seriesScoreDisplay(series *db.PlayoffSeries) {
</div>
<div class="flex flex-col items-center">
<span class="text-4xl text-subtext0 font-light leading-none"></span>
if isCompleted {
if isCompleted && isForfeit {
<span class="px-1.5 py-0.5 bg-red/20 text-red rounded text-xs font-semibold mt-1">
FORFEIT
</span>
} else if isCompleted {
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs font-semibold mt-1">
FINAL
</span>
@@ -412,6 +560,18 @@ templ seriesScoreDisplay(series *db.PlayoffSeries) {
}
</div>
</div>
if isForfeit && forfeitTeamName != "" {
<div class="text-center mt-2">
<p class="text-sm text-red/80">
{ forfeitTeamName } forfeited the series
</p>
if series.ForfeitReason != nil && *series.ForfeitReason != "" {
<p class="text-xs text-subtext0 mt-1">
{ *series.ForfeitReason }
</p>
}
</div>
}
}
</div>
</div>