Merge branch 'finals' into development

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

View File

@@ -329,6 +329,149 @@ func DeleteSeriesResults(
return nil 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. // HasPendingSeriesResults checks if a series has any pending (non-finalized) results.
func HasPendingSeriesResults(ctx context.Context, tx bun.Tx, seriesID int) (bool, error) { func HasPendingSeriesResults(ctx context.Context, tx bun.Tx, seriesID int) (bool, error) {
series, err := GetPlayoffSeriesByID(ctx, tx, seriesID) 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, Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesDiscardResults(s, conn)), 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{ 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 hasTeams := series.Team1 != nil || series.Team2 != nil
seriesURL := fmt.Sprintf("/series/%d", series.ID) seriesURL := fmt.Sprintf("/series/%d", series.ID)
isForfeit := series.IsForfeit
}} }}
<div <div
data-series={ fmt.Sprint(series.SeriesNumber) } 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", class={ "bg-surface0 border rounded-lg overflow-hidden",
templ.KV("border-blue/50", series.Status == db.SeriesStatusInProgress), 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) } templ.KV("hover:bg-surface1 hover:cursor-pointer transition", hasTeams) }
> >
<!-- Series Header --> <!-- 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> <span class="text-xs font-semibold text-subtext0">{ series.Label }</span>
@seriesFormatBadge(series.MatchesToWin) @seriesFormatBadge(series.MatchesToWin)
</div> </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> </div>
<!-- Teams --> <!-- Teams -->
<div class="divide-y divide-surface1"> <div class="divide-y divide-surface1">
@seriesTeamRow(season, league, series.Team1, series.Team1Seed, series.Team1Wins, @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, @seriesTeamRow(season, league, series.Team2, series.Team2Seed, series.Team2Wins,
series.WinnerTeamID, series.MatchesToWin) series.WinnerTeamID, series.ForfeitTeamID, series.MatchesToWin)
</div> </div>
<!-- Series Score --> <!-- 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"> <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) } { fmt.Sprint(series.Team1Wins) } - { fmt.Sprint(series.Team2Wins) }
</div> </div>
@@ -236,16 +245,21 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
</div> </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 isWinner := false
if team != nil && winnerID != nil { if team != nil && winnerID != nil {
isWinner = team.ID == *winnerID isWinner = team.ID == *winnerID
} }
isForfeiter := false
if team != nil && forfeitTeamID != nil {
isForfeiter = team.ID == *forfeitTeamID
}
isTBD := team == nil isTBD := team == nil
}} }}
<div class={ "flex items-center justify-between px-3 py-2", <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"> <div class="flex items-center gap-2 min-w-0">
if seed != nil { if seed != nil {
<span class="text-xs font-mono text-subtext0 w-4 text-right flex-shrink-0"> <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"> <div class="truncate">
@links.TeamLinkInSeason(team, season, league) @links.TeamLinkInSeason(team, season, league)
</div> </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> <span class="text-green text-xs flex-shrink-0">✓</span>
} }
} }
</div> </div>
if matchesToWin > 1 { if matchesToWin > 1 && forfeitTeamID == nil {
<span class={ "text-sm font-mono flex-shrink-0 ml-2", <span class={ "text-sm font-mono flex-shrink-0 ml-2",
templ.KV("text-text", !isWinner), templ.KV("text-text", !isWinner),
templ.KV("text-green font-bold", 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 { if hasPendingMatches {
<div class="text-4xl mb-3">📋</div> <div class="text-4xl mb-3">📋</div>
<p class="text-lg text-text font-medium mb-2">Results Pending Review</p> <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> <p class="text-sm text-subtext1 mb-4">Uploaded results are waiting to be reviewed and finalized.</p>
<a <div class="flex items-center justify-center gap-3">
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/review", series.ID)) } <a
class="inline-block px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded-lg href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/review", series.ID)) }
font-medium transition hover:cursor-pointer" 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> 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 { } else {
<div class="text-4xl mb-3">📋</div> <div class="text-4xl mb-3">📋</div>
<p class="text-lg text-text font-medium mb-2">No Results Uploaded</p> <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> <p class="text-sm text-subtext1 mb-4">Upload match log files to record the series results.</p>
<a <div class="flex items-center justify-center gap-3">
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/upload", series.ID)) } <a
class="inline-block px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/upload", series.ID)) }
font-medium transition hover:cursor-pointer" 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> 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> </div>
} }
@@ -345,11 +479,21 @@ templ seriesScoreDisplay(series *db.PlayoffSeries) {
team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID
team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID
isBye := series.Status == db.SeriesStatusBye 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-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"> <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> <h2 class="text-lg font-bold text-text">Series Score</h2>
<div class="flex items-center gap-2"> <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) @seriesStatusBadge(series.Status)
@seriesFormatBadge(series.MatchesToWin) @seriesFormatBadge(series.MatchesToWin)
</div> </div>
@@ -386,7 +530,11 @@ templ seriesScoreDisplay(series *db.PlayoffSeries) {
</div> </div>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<span class="text-4xl text-subtext0 font-light leading-none"></span> <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"> <span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs font-semibold mt-1">
FINAL FINAL
</span> </span>
@@ -412,6 +560,18 @@ templ seriesScoreDisplay(series *db.PlayoffSeries) {
} }
</div> </div>
</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>
</div> </div>