2 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
8 changed files with 528 additions and 675 deletions

View File

@@ -1,644 +0,0 @@
# oslstats Codebase Review — Implementation Plan
## Dependency Order Rationale
Changes are ordered so that **foundational/infrastructure changes come first**, which later changes depend on. This prevents doing work twice or having to revisit files.
---
## Phase 1: Foundation — Establish Response System Rules
Before fixing individual handlers, establish clear rules about when to use each response system. This is the root cause of most inconsistencies.
### 1.1 Document the three response systems
Create a comment block or doc at the top of each package (or a shared document) defining when to use each:
| System | When to use | Example |
|--------|-------------|---------|
| **`throw`** | Full-page navigation (GET requests), or errors so severe the page can't render | `throw.NotFound`, `throw.BadRequest` |
| **`notify`** | HTMX partials/modal actions where you want a WebSocket toast notification | `notify.Warn`, `notify.Success` |
| **`respond`** | Pure API-style responses (JSON body, no page render, no toast) — uniqueness checks, programmatic callers | `respond.BadRequest`, `respond.OK` |
**Rule of thumb**: If the handler renders templ → `throw` for errors. If it's an HTMX action → `notify`. If it's a pure data endpoint → `respond`.
### 1.2 Remove duplicate `parseErrorDetails`
**File**: `internal/handlers/errors.go` (lines 14-29)
This function is an exact duplicate of `notify.ParseErrorDetails` in `internal/notify/util.go` (lines 29-44).
**Fix**: Delete `parseErrorDetails` from `handlers/errors.go` and update any callers to use `notify.ParseErrorDetails` instead.
**Check for callers first:**
```
grep -rn "parseErrorDetails" internal/handlers/
```
If no callers exist (the function is unexported and may be dead code), simply delete it.
---
## Phase 2: Transaction Misuse — Read-only operations using write transactions
`WithNotifyTx` always commits (it's a write transaction with notify-based error handling). Several handlers use it for **read-only** operations. This should be `WithReadTx` (which always rolls back).
However, there's a missing helper: a `WithNotifyReadTx` that uses `notify` for errors instead of `throw`. You need to decide:
**Option A**: Add a `WithNotifyReadTx` helper to `internal/db/txhelpers.go`
**Option B**: Convert these to `WithReadTx` and accept that read-only errors render error pages
Recommendation: **Option A** — add `WithNotifyReadTx`.
### 2.1 Add `WithNotifyReadTx` to `internal/db/txhelpers.go`
After `WithReadTx` (line 38), add:
```go
// WithNotifyReadTx executes a read-only transaction with notification-based error handling
// Uses notify.InternalServiceError instead of throw.InternalServiceError
func (db *DB) WithNotifyReadTx(
s *hws.Server,
w http.ResponseWriter,
r *http.Request,
fn TxFunc,
) bool {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
ok, err := db.withTx(ctx, fn, false)
if err != nil {
notify.InternalServiceError(s, w, r, "Database error", err)
}
return ok
}
```
### 2.2 Fix read-only handlers using `WithNotifyTx`
These handlers only **read** data but use `WithNotifyTx` (write transaction):
| File | Line | Handler | Fix |
|------|------|---------|-----|
| `admin_roles.go` | 117 | `AdminRoleManage` | → `WithNotifyReadTx` |
| `admin_roles.go` | 147 | `AdminRoleDeleteConfirm` | → `WithNotifyReadTx` |
| `admin_roles.go` | 238 | `AdminRolePermissionsModal` | → `WithNotifyReadTx` |
| `isunique.go` | 36 | `IsUnique` | → `WithNotifyReadTx` |
For each, simply change `conn.WithNotifyTx` to `conn.WithNotifyReadTx`.
---
## Phase 3: Double Response / Redundant Error Handling Bugs
These are actual bugs where two HTTP responses are sent to the same request.
### 3.1 `fixtures.go:71` — Double response after `ValidateAndNotify`
**File**: `internal/handlers/fixtures.go`, lines 70-72
```go
if !getter.ValidateAndNotify(s, w, r) {
w.WriteHeader(http.StatusBadRequest) // ← BUG: ValidateAndNotify already sent notifications
return
}
```
**Fix**: Remove line 71 (`w.WriteHeader(http.StatusBadRequest)`). The `ValidateAndNotify` already handles the response via WebSocket.
### 3.2 `fixtures.go:100-103` — `respond.BadRequest` then `return false, errors.Wrap(...)`
**File**: `internal/handlers/fixtures.go`, lines 100-103
```go
if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.UpdateFixtureGameWeeks"))
}
return false, errors.Wrap(err, "db.UpdateFixtureGameWeeks") // ← runs for BOTH bad request AND other errors
```
The `return false, errors.Wrap(...)` runs unconditionally. If it's a bad request, `respond.BadRequest` fires, then `return false, err` causes `WithNotifyTx` to also call `notify.InternalServiceError`.
**Fix**: Add `return false, nil` after `respond.BadRequest`:
```go
if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.UpdateFixtureGameWeeks"))
return false, nil
}
return false, errors.Wrap(err, "db.UpdateFixtureGameWeeks")
```
### 3.3 `season_leagues.go:80-83` — Same pattern
**File**: `internal/handlers/season_leagues.go`, lines 80-83
```go
if db.IsBadRequest(err) {
respond.BadRequest(w, err)
}
return false, errors.Wrap(err, "season.RemoveLeague")
```
**Fix**: Add `return false, nil` after `respond.BadRequest`:
```go
if db.IsBadRequest(err) {
respond.BadRequest(w, err)
return false, nil
}
return false, errors.Wrap(err, "season.RemoveLeague")
```
### 3.4 `team_roster_manage.go:25-26` and `35-37` — Redundant responses
**File**: `internal/handlers/team_roster_manage.go`
```go
// Line 23-26:
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
respond.BadRequest(w, errors.New("failed to parse form")) // ← redundant
return
}
// Line 35-37:
if !getter.ValidateAndNotify(s, w, r) {
respond.BadRequest(w, errors.New("invalid form data")) // ← redundant
return
}
```
`ParseFormOrNotify` and `ValidateAndNotify` already handle the response via notifications. The extra `respond.BadRequest` sends a second response.
**Fix**: Remove both `respond.BadRequest` calls:
```go
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
// ...
if !getter.ValidateAndNotify(s, w, r) {
return
}
```
### 3.5 `free_agents.go:94-96` and `100-102` — Same redundant pattern
**File**: `internal/handlers/free_agents.go`, `RegisterFreeAgent` handler
```go
// Line 93-96:
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
respond.BadRequest(w, errors.New("failed to parse form")) // ← redundant
return
}
// Line 99-102:
if !getter.ValidateAndNotify(s, w, r) {
respond.BadRequest(w, errors.New("invalid form data")) // ← redundant
return
}
```
**Fix**: Same as 3.4 — remove both `respond.BadRequest` calls.
Also apply the same fix in the `UnregisterFreeAgent` handler (lines 167-174), which has the identical pattern.
### 3.6 `login.go:37-39` — `notify` then `respond.OK`
**File**: `internal/handlers/login.go`, lines 36-39
```go
if err != nil {
notify.ServiceUnavailable(s, w, r, "Login currently unavailable", err)
respond.OK(w) // ← sends 200 OK after the notification
return
}
```
The `notify.ServiceUnavailable` sends a WebSocket message. Then `respond.OK` writes a 200 status. The 200 status tells HTMX the request succeeded, which is misleading.
**Fix**: Remove `respond.OK(w)`. The notification alone is sufficient for the HTMX POST handler:
```go
if err != nil {
notify.ServiceUnavailable(s, w, r, "Login currently unavailable", err)
return
}
```
---
## Phase 4: Raw Response Patterns → Use Proper Helpers
### 4.1 `season_league_add_team.go:42` — Raw `w.WriteHeader`
**File**: `internal/handlers/season_league_add_team.go`, line 42
```go
if db.IsBadRequest(err) {
w.WriteHeader(http.StatusBadRequest) // ← raw header, no error body
return false, nil
}
```
**Fix**: Use `notify.Warn` since this is inside a `WithNotifyTx` context:
```go
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Invalid Request", err.Error(), nil)
return false, nil
}
```
### 4.2 `season_league_add_team.go:53-54` — Manual HX-Redirect
**File**: `internal/handlers/season_league_add_team.go`, lines 53-54
```go
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName))
w.WriteHeader(http.StatusOK)
```
**Fix**: Use `respond.HXRedirect`:
```go
respond.HXRedirect(w, "/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName)
```
This will also require adding `"git.haelnorr.com/h/oslstats/internal/respond"` to the imports and potentially removing `"fmt"` if no longer used.
### 4.3 `admin_roles.go:142` — Raw `w.WriteHeader`
**File**: `internal/handlers/admin_roles.go`, line 142
```go
roleID, err := strconv.Atoi(roleIDStr)
if err != nil {
w.WriteHeader(http.StatusBadRequest) // ← raw header
return
}
```
**Fix**: Use `respond.BadRequest`:
```go
roleID, err := strconv.Atoi(roleIDStr)
if err != nil {
respond.BadRequest(w, err)
return
}
```
---
## Phase 5: `strconv.Atoi` Error Handling Standardization
Currently, `strconv.Atoi` failures for path parameters are handled inconsistently:
| Pattern | Used in |
|---------|---------|
| `throw.BadRequest(s, w, r, "Invalid X ID", err)` | `fixture_detail.go` (x15), `forfeit.go`, `fixture_result.go` (x4), `free_agents.go` (x2), `admin_preview_role.go`, `admin_audit.go` |
| `throw.NotFound(s, w, r, r.URL.Path)` | `team_detail.go`, `season_league_team_detail.go`, `player_view.go`, `player_link_slapid.go` |
| `respond.BadRequest(w, err)` | `admin_roles.go` (x4), `fixtures.go:121` |
| `w.WriteHeader(http.StatusBadRequest)` | `admin_roles.go:142` |
**Recommended standard**: For path parameters in URL routing:
- If it's a page navigation (user-visible URL) → `throw.NotFound` (an invalid ID in a URL means the resource doesn't exist)
- If it's an HTMX action endpoint → `respond.BadRequest` (the ID came from the UI, so it's a programming error or tampered request)
Apply this consistently:
### Files to update:
**To `throw.NotFound`** (page navigation handlers):
- No changes needed — `team_detail.go`, `season_league_team_detail.go`, `player_view.go` already do this correctly
**To `respond.BadRequest`** (HTMX action endpoints):
- `admin_roles.go:110` — Already uses `respond.BadRequest`
- `admin_roles.go:170`, `227`, `291` — Already use `respond.BadRequest`
- `admin_roles.go:142` — Fixed in Phase 4.3
**Mixed handlers** (both page navigation and HTMX actions):
- `fixture_detail.go` — The page-rendering handlers use `throw.BadRequest`. Since these are often navigated to via URL, consider changing to `throw.NotFound` for the "page" handlers, and keeping `throw.BadRequest` for the action handlers. This is a judgment call — at minimum, be consistent within the file.
---
## Phase 6: `sql.ErrNoRows` Comparison Fix
### 6.1 `fixture.go:51` — String comparison for `sql.ErrNoRows`
**File**: `internal/db/fixture.go`, line 51
```go
if err.Error() == "sql: no rows in result set" {
return false, 0, nil
}
```
**Fix**: Use proper error comparison:
```go
import "database/sql"
// ...
if errors.Is(err, sql.ErrNoRows) {
return false, 0, nil
}
```
This requires adding `"database/sql"` to imports (and `"github.com/pkg/errors"` may already handle `Is` — check if you need to also import the standard `errors` package or if `pkg/errors` supports `Is`).
---
## Phase 7: Code Style Fixes
### 7.1 `season.go:70` — `errors.WithMessage` instead of `errors.Wrap`
**File**: `internal/db/season.go`, line 70
```go
return nil, errors.WithMessage(err, "db.Insert")
```
**Fix**: Change to `errors.Wrap`:
```go
return nil, errors.Wrap(err, "db.Insert")
```
`errors.WithMessage` doesn't capture a new stack frame; `errors.Wrap` does. The codebase convention is `errors.Wrap`.
### 7.2 `register.go:67` — Wrong function name in error wrap
**File**: `internal/handlers/register.go`, line 67
```go
return false, errors.Wrap(err, "db.IsUsernameUnique")
```
The actual function called is `db.IsUnique` (line 65). The wrap message references a non-existent function.
**Fix**:
```go
return false, errors.Wrap(err, "db.IsUnique")
```
### 7.3 `fixtures.go:177` — Profanity in comment
**File**: `internal/handlers/fixtures.go`, line 177
```go
// fuck i hate pointers sometimes
```
**Fix**: Remove or rephrase:
```go
// Pointer handling for optional game week values
```
### 7.4 `register.go:63` — Audit with nil user
**File**: `internal/handlers/register.go`, line 63
```go
var user *db.User
audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user) // user is nil here
```
This passes a nil user to `NewAudit` before the user exists. The audit will have no user associated. This is somewhat intentional (registration happens before the user exists), but it's worth documenting:
**Fix**: Add a comment explaining the intentional nil:
```go
var user *db.User
// User is nil before registration — audit tracks the request source (IP/UA) only
audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user)
```
---
## Phase 8: Validation Interface Gap
### 8.1 `TimeInLocation` missing from `Getter` interface
**File**: `internal/validation/validation.go`, lines 22-38
The `Getter` interface defines `Time(key, format)` but not `TimeInLocation(key, format, loc)`. The `FormGetter` has `TimeInLocation` (line 95 of `forms.go`), but `QueryGetter` doesn't, and neither does the interface.
**Fix**: Add `TimeInLocation` to the `Getter` interface and to `QueryGetter`:
In `validation.go`, add to the `Getter` interface:
```go
TimeInLocation(key string, format *timefmt.Format, loc *time.Location) *TimeField
```
In `querys.go`, add the method:
```go
func (q *QueryGetter) TimeInLocation(key string, format *timefmt.Format, loc *time.Location) *TimeField {
return newTimeFieldInLocation(key, format, loc, q)
}
```
Also add `"time"` to the imports of `querys.go`.
---
## Phase 9: Duplicate Code Consolidation
### 9.1 Duplicate leaderboard computation pattern
The same pattern appears in **5 places**:
1. `handlers/index.go:54-69`
2. `handlers/season_league_table.go:40-55`
3. `handlers/season_league_team_detail.go:87-100`
4. `db/team.go:126-142`
5. `db/match_preview.go:222`
The pattern is:
```go
fixtures, err := db.GetAllocatedFixtures(ctx, tx, seasonID, leagueID)
fixtureIDs := make([]int, len(fixtures))
for i, f := range fixtures { fixtureIDs[i] = f.ID }
resultMap, err := db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
leaderboard := db.ComputeLeaderboard(teams, fixtures, resultMap)
```
**Fix**: Create a helper function in `internal/db/` (e.g. in `fixture_result.go` near `ComputeLeaderboard`):
```go
// GetLeaderboard fetches fixtures and results, then computes the leaderboard for a season+league.
func GetLeaderboard(ctx context.Context, tx bun.Tx, seasonID, leagueID int, teams []*Team) ([]*LeaderboardEntry, error) {
fixtures, err := GetAllocatedFixtures(ctx, tx, seasonID, leagueID)
if err != nil {
return nil, errors.Wrap(err, "GetAllocatedFixtures")
}
fixtureIDs := make([]int, len(fixtures))
for i, f := range fixtures {
fixtureIDs[i] = f.ID
}
resultMap, err := GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
if err != nil {
return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures")
}
return ComputeLeaderboard(teams, fixtures, resultMap), nil
}
```
Then replace all 5 occurrences with a single call:
```go
leaderboard, err := db.GetLeaderboard(ctx, tx, seasonID, leagueID, teams)
if err != nil {
return false, errors.Wrap(err, "db.GetLeaderboard")
}
```
### 9.2 Duplicate player stats SQL
**File**: `internal/db/player.go`, lines 116-206
Three functions — `GetPlayerAllTimeStats`, `GetPlayerStatsBySeason`, `GetPlayerStatsByTeam` — share 90% identical SQL. Only the `WHERE` clause differs.
**Fix**: Extract a common base function:
```go
func getPlayerStats(ctx context.Context, tx bun.Tx, playerID int, extraJoin string, extraWhere string, extraArgs ...any) (*PlayerAllTimeStats, error) {
if playerID == 0 {
return nil, errors.New("playerID not provided")
}
stats := new(PlayerAllTimeStats)
query := `
SELECT
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.goals), 0) AS total_goals,
COALESCE(SUM(frps.assists), 0) AS total_assists,
COALESCE(SUM(frps.saves), 0) AS total_saves,
COALESCE(SUM(frps.shots), 0) AS total_shots,
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
COALESCE(SUM(frps.passes), 0) AS total_passes
FROM fixture_result_player_stats frps
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
` + extraJoin + `
WHERE fr.finalized = true
AND frps.player_id = ?
AND frps.period_num = 3
` + extraWhere
args := append([]any{playerID}, extraArgs...)
err := tx.NewRaw(query, args...).Scan(ctx, stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
func GetPlayerAllTimeStats(ctx context.Context, tx bun.Tx, playerID int) (*PlayerAllTimeStats, error) {
return getPlayerStats(ctx, tx, playerID, "", "")
}
func GetPlayerStatsBySeason(ctx context.Context, tx bun.Tx, playerID, seasonID int) (*PlayerAllTimeStats, error) {
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
return getPlayerStats(ctx, tx, playerID,
"JOIN fixtures f ON f.id = fr.fixture_id",
"AND f.season_id = ?", seasonID)
}
func GetPlayerStatsByTeam(ctx context.Context, tx bun.Tx, playerID, teamID int) (*PlayerAllTimeStats, error) {
if teamID == 0 {
return nil, errors.New("teamID not provided")
}
return getPlayerStats(ctx, tx, playerID,
"",
"AND frps.team_id = ?", teamID)
}
```
---
## Phase 10: Pattern Consistency (Lower Priority)
### 10.1 Leagues list page — missing GET/POST pattern and pagination
**File**: `internal/handlers/leagues_list.go`
Unlike `seasons_list.go` and `teams_list.go`, this handler:
1. Always renders the full page (no GET vs POST check)
2. Fetches ALL leagues without pagination
**Fix**: Follow the established pattern from `seasons_list.go`:
```go
func LeaguesList(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pageOpts, ok := db.GetPageOpts(s, w, r)
if !ok {
return
}
var leagues *db.List[db.League]
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
leagues, err = db.ListLeagues(ctx, tx, pageOpts) // New paginated function
if err != nil {
return false, errors.Wrap(err, "db.ListLeagues")
}
return true, nil
}); !ok {
return
}
if r.Method == "GET" {
renderSafely(leaguesview.ListPage(leagues), s, r, w)
} else {
renderSafely(leaguesview.LeaguesList(leagues), s, r, w)
}
})
}
```
This requires:
- Adding a `ListLeagues` function in `internal/db/league.go` using `GetList[League](tx).GetPaged(ctx, pageOpts, defaults)`
- Updating the template to support the new paginated list pattern
- Adding a `LeaguesList` partial template
**Note**: This is a more involved change and may warrant its own feature task.
---
## Summary Checklist
| # | Phase | Issue | Severity | Files |
|---|-------|-------|----------|-------|
| 1.2 | Foundation | Duplicate `parseErrorDetails` | Low | `handlers/errors.go` |
| 2.1 | Transactions | Add `WithNotifyReadTx` | Medium | `db/txhelpers.go` |
| 2.2 | Transactions | Fix 4 read-only handlers | Medium | `admin_roles.go`, `isunique.go` |
| 3.1 | Double Response | `ValidateAndNotify` + `WriteHeader` | **High** | `fixtures.go:71` |
| 3.2 | Double Response | `respond.BadRequest` + `return err` | **High** | `fixtures.go:100-103` |
| 3.3 | Double Response | `respond.BadRequest` + `return err` | **High** | `season_leagues.go:80-83` |
| 3.4 | Double Response | Redundant respond after notify | **High** | `team_roster_manage.go:25,36` |
| 3.5 | Double Response | Redundant respond after notify | **High** | `free_agents.go:95,101,168,174` |
| 3.6 | Double Response | `notify` then `respond.OK` | **High** | `login.go:38` |
| 4.1 | Raw Responses | Raw `w.WriteHeader` | Medium | `season_league_add_team.go:42` |
| 4.2 | Raw Responses | Manual HX-Redirect | Low | `season_league_add_team.go:53-54` |
| 4.3 | Raw Responses | Raw `w.WriteHeader` | Medium | `admin_roles.go:142` |
| 5 | Standardization | `strconv.Atoi` error handling | Low | Multiple files |
| 6.1 | Bug Fix | String comparison for `sql.ErrNoRows` | **High** | `db/fixture.go:51` |
| 7.1 | Style | `errors.WithMessage``errors.Wrap` | Low | `db/season.go:70` |
| 7.2 | Style | Wrong wrap message | Low | `register.go:67` |
| 7.3 | Style | Profanity in comment | Low | `fixtures.go:177` |
| 7.4 | Style | Nil user audit — add comment | Low | `register.go:63` |
| 8.1 | Interface | `TimeInLocation` missing from `Getter` | Medium | `validation/validation.go`, `querys.go` |
| 9.1 | Duplication | Leaderboard computation | Medium | 5 files |
| 9.2 | Duplication | Player stats SQL | Medium | `db/player.go` |
| 10.1 | Consistency | Leagues list pattern | Low | `leagues_list.go`, `db/league.go`, templates |
**Recommended execution order**: Phases 1 → 2 → 3 → 6 → 4 → 7 → 8 → 9 → 5 → 10

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>