Compare commits
33 Commits
review
...
ba0844048a
| Author | SHA1 | Date | |
|---|---|---|---|
| ba0844048a | |||
| 1cab39a4f7 | |||
| 26ee81964d | |||
| e0fd3b0a45 | |||
| 34cba6a81f | |||
| 14e10d095e | |||
| dd1ed61adb | |||
| 9ad29586f2 | |||
| 78db8d0324 | |||
| 04389970ac | |||
| 1194d46613 | |||
| 3b1eeaf12d | |||
| 4064c9c557 | |||
| 9f6a2303a0 | |||
| 8b414ff7f0 | |||
| 7b934295c6 | |||
| c73758c91c | |||
| f5c9e70edf | |||
| b957df8d32 | |||
| b96aeef32e | |||
| ce659f7d56 | |||
| a472314474 | |||
| 76987adceb | |||
| 060301f2c2 | |||
| 788346d269 | |||
| b57fbcd302 | |||
| 1634b27991 | |||
| cade057e42 | |||
| e526f42ac3 | |||
| 088478e6c1 | |||
| 08344877c7 | |||
| 36b42d6267 | |||
| 2b5c43cf61 |
644
REVIEW_PLAN.md
644
REVIEW_PLAN.md
@@ -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
|
|
||||||
@@ -513,7 +513,6 @@ func GetAllLeaguePlayerStats(
|
|||||||
WHERE fr.finalized = true
|
WHERE fr.finalized = true
|
||||||
AND f.season_id = ?
|
AND f.season_id = ?
|
||||||
AND f.league_id = ?
|
AND f.league_id = ?
|
||||||
AND f.round > 0
|
|
||||||
AND frps.period_num = 3
|
AND frps.period_num = 3
|
||||||
AND frps.player_id IS NOT NULL
|
AND frps.player_id IS NOT NULL
|
||||||
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
||||||
@@ -584,7 +583,6 @@ func GetTopGoalScorers(
|
|||||||
WHERE fr.finalized = true
|
WHERE fr.finalized = true
|
||||||
AND f.season_id = ?
|
AND f.season_id = ?
|
||||||
AND f.league_id = ?
|
AND f.league_id = ?
|
||||||
AND f.round > 0
|
|
||||||
AND frps.period_num = 3
|
AND frps.period_num = 3
|
||||||
AND frps.player_id IS NOT NULL
|
AND frps.player_id IS NOT NULL
|
||||||
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
||||||
@@ -657,7 +655,6 @@ func GetTopAssisters(
|
|||||||
WHERE fr.finalized = true
|
WHERE fr.finalized = true
|
||||||
AND f.season_id = ?
|
AND f.season_id = ?
|
||||||
AND f.league_id = ?
|
AND f.league_id = ?
|
||||||
AND f.round > 0
|
|
||||||
AND frps.period_num = 3
|
AND frps.period_num = 3
|
||||||
AND frps.player_id IS NOT NULL
|
AND frps.player_id IS NOT NULL
|
||||||
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
||||||
@@ -730,7 +727,6 @@ func GetTopSavers(
|
|||||||
WHERE fr.finalized = true
|
WHERE fr.finalized = true
|
||||||
AND f.season_id = ?
|
AND f.season_id = ?
|
||||||
AND f.league_id = ?
|
AND f.league_id = ?
|
||||||
AND f.round > 0
|
|
||||||
AND frps.period_num = 3
|
AND frps.period_num = 3
|
||||||
AND frps.player_id IS NOT NULL
|
AND frps.player_id IS NOT NULL
|
||||||
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
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 {
|
|
||||||
_, err := conn.NewCreateTable().
|
|
||||||
Model((*db.PlayoffSeriesSchedule)(nil)).
|
|
||||||
IfNotExists().
|
|
||||||
ForeignKey(`("series_id") REFERENCES "playoff_series" ("id") ON DELETE CASCADE`).
|
|
||||||
ForeignKey(`("proposed_by_team_id") REFERENCES "teams" ("id")`).
|
|
||||||
ForeignKey(`("accepted_by_team_id") REFERENCES "teams" ("id")`).
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create index on series_id for faster lookups
|
|
||||||
_, err = conn.NewCreateIndex().
|
|
||||||
Model((*db.PlayoffSeriesSchedule)(nil)).
|
|
||||||
Index("idx_playoff_series_schedules_series_id").
|
|
||||||
Column("series_id").
|
|
||||||
IfNotExists().
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create index on status for filtering
|
|
||||||
_, err = conn.NewCreateIndex().
|
|
||||||
Model((*db.PlayoffSeriesSchedule)(nil)).
|
|
||||||
Index("idx_playoff_series_schedules_status").
|
|
||||||
Column("status").
|
|
||||||
IfNotExists().
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
// DOWN migration
|
|
||||||
func(ctx context.Context, conn *bun.DB) error {
|
|
||||||
_, err := conn.NewDropTable().
|
|
||||||
Model((*db.PlayoffSeriesSchedule)(nil)).
|
|
||||||
IfExists().
|
|
||||||
Exec(ctx)
|
|
||||||
return err
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -324,224 +324,3 @@ func AutoForfeitUnplayedFixtures(
|
|||||||
|
|
||||||
return len(unplayed), nil
|
return len(unplayed), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPlayoffSeriesByID retrieves a single playoff series with all relations needed
|
|
||||||
// for the series detail page.
|
|
||||||
func GetPlayoffSeriesByID(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
seriesID int,
|
|
||||||
) (*PlayoffSeries, error) {
|
|
||||||
series := new(PlayoffSeries)
|
|
||||||
err := tx.NewSelect().
|
|
||||||
Model(series).
|
|
||||||
Where("ps.id = ?", seriesID).
|
|
||||||
Relation("Bracket").
|
|
||||||
Relation("Bracket.Season").
|
|
||||||
Relation("Bracket.League").
|
|
||||||
Relation("Bracket.Series").
|
|
||||||
Relation("Team1").
|
|
||||||
Relation("Team2").
|
|
||||||
Relation("Winner").
|
|
||||||
Relation("Loser").
|
|
||||||
Relation("Matches", func(q *bun.SelectQuery) *bun.SelectQuery {
|
|
||||||
return q.Order("pm.match_number ASC")
|
|
||||||
}).
|
|
||||||
Scan(ctx)
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "sql: no rows in result set" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
|
||||||
}
|
|
||||||
return series, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanScheduleSeries checks if the user is a manager of one of the teams in the series.
|
|
||||||
// Returns (canSchedule, teamID) where teamID is the team the user manages (0 if not a manager).
|
|
||||||
// Both teams must be assigned for scheduling to be possible.
|
|
||||||
func CanScheduleSeries(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
series *PlayoffSeries,
|
|
||||||
user *User,
|
|
||||||
) (bool, int, error) {
|
|
||||||
if user == nil || user.Player == nil {
|
|
||||||
return false, 0, nil
|
|
||||||
}
|
|
||||||
if series.Team1ID == nil || series.Team2ID == nil {
|
|
||||||
return false, 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
roster := new(TeamRoster)
|
|
||||||
err := tx.NewSelect().
|
|
||||||
Model(roster).
|
|
||||||
Column("team_id", "is_manager").
|
|
||||||
Where("team_id IN (?)", bun.In([]int{*series.Team1ID, *series.Team2ID})).
|
|
||||||
Where("season_id = ?", series.Bracket.SeasonID).
|
|
||||||
Where("league_id = ?", series.Bracket.LeagueID).
|
|
||||||
Where("player_id = ?", user.Player.ID).
|
|
||||||
Scan(ctx)
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "sql: no rows in result set" {
|
|
||||||
return false, 0, nil
|
|
||||||
}
|
|
||||||
return false, 0, errors.Wrap(err, "tx.NewSelect")
|
|
||||||
}
|
|
||||||
if !roster.IsManager {
|
|
||||||
return false, 0, nil
|
|
||||||
}
|
|
||||||
return true, roster.TeamID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSeriesTeamRosters returns rosters for both teams in a series.
|
|
||||||
// Returns map["team1"|"team2"] -> []*PlayerWithPlayStatus
|
|
||||||
func GetSeriesTeamRosters(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
series *PlayoffSeries,
|
|
||||||
) (map[string][]*PlayerWithPlayStatus, error) {
|
|
||||||
if series == nil {
|
|
||||||
return nil, errors.New("series cannot be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
rosters := map[string][]*PlayerWithPlayStatus{}
|
|
||||||
|
|
||||||
if series.Team1ID != nil {
|
|
||||||
team1Rosters := []*TeamRoster{}
|
|
||||||
err := tx.NewSelect().
|
|
||||||
Model(&team1Rosters).
|
|
||||||
Where("tr.team_id = ?", *series.Team1ID).
|
|
||||||
Where("tr.season_id = ?", series.Bracket.SeasonID).
|
|
||||||
Where("tr.league_id = ?", series.Bracket.LeagueID).
|
|
||||||
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
|
||||||
return q.Relation("User")
|
|
||||||
}).
|
|
||||||
Scan(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "tx.NewSelect team1 roster")
|
|
||||||
}
|
|
||||||
for _, tr := range team1Rosters {
|
|
||||||
rosters["team1"] = append(rosters["team1"], &PlayerWithPlayStatus{
|
|
||||||
Player: tr.Player,
|
|
||||||
Played: false,
|
|
||||||
IsManager: tr.IsManager,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if series.Team2ID != nil {
|
|
||||||
team2Rosters := []*TeamRoster{}
|
|
||||||
err := tx.NewSelect().
|
|
||||||
Model(&team2Rosters).
|
|
||||||
Where("tr.team_id = ?", *series.Team2ID).
|
|
||||||
Where("tr.season_id = ?", series.Bracket.SeasonID).
|
|
||||||
Where("tr.league_id = ?", series.Bracket.LeagueID).
|
|
||||||
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
|
||||||
return q.Relation("User")
|
|
||||||
}).
|
|
||||||
Scan(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "tx.NewSelect team2 roster")
|
|
||||||
}
|
|
||||||
for _, tr := range team2Rosters {
|
|
||||||
rosters["team2"] = append(rosters["team2"], &PlayerWithPlayStatus{
|
|
||||||
Player: tr.Player,
|
|
||||||
Played: false,
|
|
||||||
IsManager: tr.IsManager,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rosters, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ComputeSeriesPreview computes standings comparison data for the two teams in a series.
|
|
||||||
// Uses the same logic as ComputeMatchPreview but takes a series instead of a fixture.
|
|
||||||
func ComputeSeriesPreview(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
series *PlayoffSeries,
|
|
||||||
) (*MatchPreviewData, error) {
|
|
||||||
if series == nil || series.Bracket == nil {
|
|
||||||
return nil, errors.New("series and bracket cannot be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
seasonID := series.Bracket.SeasonID
|
|
||||||
leagueID := series.Bracket.LeagueID
|
|
||||||
|
|
||||||
// Get all teams in this season+league
|
|
||||||
allTeams, err := GetTeamsForSeasonLeague(ctx, tx, seasonID, leagueID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "GetTeamsForSeasonLeague")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all allocated fixtures for the season+league
|
|
||||||
allFixtures, err := GetAllocatedFixtures(ctx, tx, seasonID, leagueID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "GetAllocatedFixtures")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get finalized results
|
|
||||||
allFixtureIDs := make([]int, len(allFixtures))
|
|
||||||
for i, f := range allFixtures {
|
|
||||||
allFixtureIDs[i] = f.ID
|
|
||||||
}
|
|
||||||
allResultMap, err := GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get accepted schedules for ordering recent games
|
|
||||||
allScheduleMap, err := GetAcceptedSchedulesForFixtures(ctx, tx, allFixtureIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "GetAcceptedSchedulesForFixtures")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute leaderboard
|
|
||||||
leaderboard := ComputeLeaderboard(allTeams, allFixtures, allResultMap)
|
|
||||||
|
|
||||||
preview := &MatchPreviewData{
|
|
||||||
TotalTeams: len(leaderboard),
|
|
||||||
}
|
|
||||||
|
|
||||||
team1ID := 0
|
|
||||||
team2ID := 0
|
|
||||||
if series.Team1ID != nil {
|
|
||||||
team1ID = *series.Team1ID
|
|
||||||
}
|
|
||||||
if series.Team2ID != nil {
|
|
||||||
team2ID = *series.Team2ID
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range leaderboard {
|
|
||||||
if entry.Team.ID == team1ID {
|
|
||||||
preview.HomePosition = entry.Position
|
|
||||||
preview.HomeRecord = entry.Record
|
|
||||||
}
|
|
||||||
if entry.Team.ID == team2ID {
|
|
||||||
preview.AwayPosition = entry.Position
|
|
||||||
preview.AwayRecord = entry.Record
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if preview.HomeRecord == nil {
|
|
||||||
preview.HomeRecord = &TeamRecord{}
|
|
||||||
}
|
|
||||||
if preview.AwayRecord == nil {
|
|
||||||
preview.AwayRecord = &TeamRecord{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute recent games (last 5) for each team
|
|
||||||
if team1ID > 0 {
|
|
||||||
preview.HomeRecentGames = ComputeRecentGames(
|
|
||||||
team1ID, allFixtures, allResultMap, allScheduleMap, 5,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if team2ID > 0 {
|
|
||||||
preview.AwayRecentGames = ComputeRecentGames(
|
|
||||||
team2ID, allFixtures, allResultMap, allScheduleMap, 5,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return preview, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,355 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/uptrace/bun"
|
|
||||||
)
|
|
||||||
|
|
||||||
// playoffFixtureRound generates a unique negative round number for a playoff game fixture.
|
|
||||||
// Format: -(seriesID * 100 + matchNumber) to avoid collision with regular season rounds.
|
|
||||||
func playoffFixtureRound(seriesID, matchNumber int) int {
|
|
||||||
return -(seriesID*100 + matchNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreatePlayoffGameFixture creates a Fixture record for a playoff game.
|
|
||||||
// The fixture is linked to the series via a PlayoffMatch record.
|
|
||||||
// team1 is "home", team2 is "away" in fixture terms.
|
|
||||||
func CreatePlayoffGameFixture(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
series *PlayoffSeries,
|
|
||||||
matchNumber int,
|
|
||||||
audit *AuditMeta,
|
|
||||||
) (*Fixture, *PlayoffMatch, error) {
|
|
||||||
if series == nil || series.Bracket == nil {
|
|
||||||
return nil, nil, errors.New("series and bracket cannot be nil")
|
|
||||||
}
|
|
||||||
if series.Team1ID == nil || series.Team2ID == nil {
|
|
||||||
return nil, nil, BadRequest("both teams must be assigned to create a game fixture")
|
|
||||||
}
|
|
||||||
|
|
||||||
round := playoffFixtureRound(series.ID, matchNumber)
|
|
||||||
|
|
||||||
fixture := &Fixture{
|
|
||||||
SeasonID: series.Bracket.SeasonID,
|
|
||||||
LeagueID: series.Bracket.LeagueID,
|
|
||||||
HomeTeamID: *series.Team1ID,
|
|
||||||
AwayTeamID: *series.Team2ID,
|
|
||||||
Round: round,
|
|
||||||
CreatedAt: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := Insert(tx, fixture).WithAudit(audit, &AuditInfo{
|
|
||||||
Action: "playoff_fixture.create",
|
|
||||||
ResourceType: "fixture",
|
|
||||||
ResourceID: nil,
|
|
||||||
Details: map[string]any{
|
|
||||||
"series_id": series.ID,
|
|
||||||
"match_number": matchNumber,
|
|
||||||
"home_team_id": *series.Team1ID,
|
|
||||||
"away_team_id": *series.Team2ID,
|
|
||||||
},
|
|
||||||
}).Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, errors.Wrap(err, "Insert fixture")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create or update PlayoffMatch record
|
|
||||||
match := new(PlayoffMatch)
|
|
||||||
err = tx.NewSelect().
|
|
||||||
Model(match).
|
|
||||||
Where("pm.series_id = ?", series.ID).
|
|
||||||
Where("pm.match_number = ?", matchNumber).
|
|
||||||
Scan(ctx)
|
|
||||||
|
|
||||||
if err != nil && err.Error() != "sql: no rows in result set" {
|
|
||||||
return nil, nil, errors.Wrap(err, "tx.NewSelect playoff_match")
|
|
||||||
}
|
|
||||||
|
|
||||||
if match.ID > 0 {
|
|
||||||
// Update existing match with fixture ID
|
|
||||||
match.FixtureID = &fixture.ID
|
|
||||||
match.HomeTeamID = series.Team1ID
|
|
||||||
match.AwayTeamID = series.Team2ID
|
|
||||||
match.Status = "pending"
|
|
||||||
err = UpdateByID(tx, match.ID, match).
|
|
||||||
Column("fixture_id", "home_team_id", "away_team_id", "status").
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, errors.Wrap(err, "UpdateByID playoff_match")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Create new match
|
|
||||||
match = &PlayoffMatch{
|
|
||||||
SeriesID: series.ID,
|
|
||||||
MatchNumber: matchNumber,
|
|
||||||
HomeTeamID: series.Team1ID,
|
|
||||||
AwayTeamID: series.Team2ID,
|
|
||||||
FixtureID: &fixture.ID,
|
|
||||||
Status: "pending",
|
|
||||||
}
|
|
||||||
err = Insert(tx, match).Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, errors.Wrap(err, "Insert playoff_match")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load fixture relations
|
|
||||||
fixture.Season = series.Bracket.Season
|
|
||||||
fixture.League = series.Bracket.League
|
|
||||||
fixture.HomeTeam = series.Team1
|
|
||||||
fixture.AwayTeam = series.Team2
|
|
||||||
|
|
||||||
return fixture, match, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FinalizeSeriesResults finalizes all pending game results for a series,
|
|
||||||
// updates series wins/status, and advances teams as needed.
|
|
||||||
// Returns the number of games finalized.
|
|
||||||
func FinalizeSeriesResults(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
seriesID int,
|
|
||||||
audit *AuditMeta,
|
|
||||||
) (int, error) {
|
|
||||||
series, err := GetPlayoffSeriesByID(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
return 0, BadRequest("series not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
if series.Status == SeriesStatusCompleted {
|
|
||||||
return 0, BadRequest("series is already completed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all matches with fixtures that have pending results
|
|
||||||
gamesFinalized := 0
|
|
||||||
team1Wins := 0
|
|
||||||
team2Wins := 0
|
|
||||||
|
|
||||||
for _, match := range series.Matches {
|
|
||||||
if match.FixtureID == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := GetFixtureResult(ctx, tx, *match.FixtureID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "GetFixtureResult")
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finalize the fixture result if pending
|
|
||||||
if !result.Finalized {
|
|
||||||
err = FinalizeFixtureResult(ctx, tx, *match.FixtureID, audit)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "FinalizeFixtureResult")
|
|
||||||
}
|
|
||||||
gamesFinalized++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update match status
|
|
||||||
now := time.Now().Unix()
|
|
||||||
match.Status = "completed"
|
|
||||||
err = UpdateByID(tx, match.ID, match).
|
|
||||||
Column("status").
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "UpdateByID playoff_match")
|
|
||||||
}
|
|
||||||
_ = now
|
|
||||||
|
|
||||||
// Count wins: team1 = home, team2 = away in fixture terms
|
|
||||||
if result.Winner == "home" {
|
|
||||||
team1Wins++
|
|
||||||
} else {
|
|
||||||
team2Wins++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if gamesFinalized == 0 {
|
|
||||||
return 0, BadRequest("no pending results to finalize")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update series wins
|
|
||||||
series.Team1Wins = team1Wins
|
|
||||||
series.Team2Wins = team2Wins
|
|
||||||
|
|
||||||
// Determine if series is decided
|
|
||||||
if team1Wins >= series.MatchesToWin || team2Wins >= series.MatchesToWin {
|
|
||||||
series.Status = SeriesStatusCompleted
|
|
||||||
|
|
||||||
if team1Wins >= series.MatchesToWin {
|
|
||||||
series.WinnerTeamID = series.Team1ID
|
|
||||||
series.LoserTeamID = series.Team2ID
|
|
||||||
} else {
|
|
||||||
series.WinnerTeamID = series.Team2ID
|
|
||||||
series.LoserTeamID = series.Team1ID
|
|
||||||
}
|
|
||||||
|
|
||||||
err = UpdateByID(tx, series.ID, series).
|
|
||||||
Column("team1_wins", "team2_wins", "status", "winner_team_id", "loser_team_id").
|
|
||||||
WithAudit(audit, &AuditInfo{
|
|
||||||
Action: "playoff_series.complete",
|
|
||||||
ResourceType: "playoff_series",
|
|
||||||
ResourceID: series.ID,
|
|
||||||
Details: map[string]any{
|
|
||||||
"team1_wins": team1Wins,
|
|
||||||
"team2_wins": team2Wins,
|
|
||||||
"winner_team_id": series.WinnerTeamID,
|
|
||||||
"loser_team_id": series.LoserTeamID,
|
|
||||||
},
|
|
||||||
}).Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "UpdateByID series complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advance winner to next series
|
|
||||||
if series.WinnerNextID != nil && series.WinnerNextSlot != nil {
|
|
||||||
err = advanceTeamToSeries(ctx, tx, *series.WinnerNextID, *series.WinnerNextSlot, *series.WinnerTeamID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "advanceTeamToSeries winner")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advance loser to next series (e.g. third place, lower bracket)
|
|
||||||
if series.LoserNextID != nil && series.LoserNextSlot != nil {
|
|
||||||
err = advanceTeamToSeries(ctx, tx, *series.LoserNextID, *series.LoserNextSlot, *series.LoserTeamID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "advanceTeamToSeries loser")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Series still in progress
|
|
||||||
series.Status = SeriesStatusInProgress
|
|
||||||
err = UpdateByID(tx, series.ID, series).
|
|
||||||
Column("team1_wins", "team2_wins", "status").
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "UpdateByID series in_progress")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return gamesFinalized, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// advanceTeamToSeries places a team into the specified slot of the target series.
|
|
||||||
func advanceTeamToSeries(ctx context.Context, tx bun.Tx, targetSeriesID int, slot string, teamID int) error {
|
|
||||||
switch slot {
|
|
||||||
case "team1":
|
|
||||||
_, err := tx.NewUpdate().
|
|
||||||
Model((*PlayoffSeries)(nil)).
|
|
||||||
Set("team1_id = ?", teamID).
|
|
||||||
Where("id = ?", targetSeriesID).
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "update team1_id")
|
|
||||||
}
|
|
||||||
case "team2":
|
|
||||||
_, err := tx.NewUpdate().
|
|
||||||
Model((*PlayoffSeries)(nil)).
|
|
||||||
Set("team2_id = ?", teamID).
|
|
||||||
Where("id = ?", targetSeriesID).
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "update team2_id")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return BadRequest("invalid slot: " + slot)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteSeriesResults deletes all pending (non-finalized) fixture results
|
|
||||||
// and their associated fixtures for a series.
|
|
||||||
func DeleteSeriesResults(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
seriesID int,
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if result.Finalized {
|
|
||||||
return BadRequest("cannot discard finalized results")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the result (CASCADE deletes player stats)
|
|
||||||
err = DeleteFixtureResult(ctx, tx, *match.FixtureID, audit)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "DeleteFixtureResult")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the fixture
|
|
||||||
err = DeleteByID[Fixture](tx, *match.FixtureID).
|
|
||||||
WithAudit(audit, &AuditInfo{
|
|
||||||
Action: "playoff_fixture.delete",
|
|
||||||
ResourceType: "fixture",
|
|
||||||
ResourceID: *match.FixtureID,
|
|
||||||
}).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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, match := range series.Matches {
|
|
||||||
if match.FixtureID == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result, err := GetPendingFixtureResult(ctx, tx, *match.FixtureID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "GetPendingFixtureResult")
|
|
||||||
}
|
|
||||||
if result != nil {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/uptrace/bun"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PlayoffSeriesSchedule represents a schedule proposal for a playoff series.
|
|
||||||
// Mirrors FixtureSchedule but references a series instead of a fixture.
|
|
||||||
type PlayoffSeriesSchedule struct {
|
|
||||||
bun.BaseModel `bun:"table:playoff_series_schedules,alias:pss"`
|
|
||||||
|
|
||||||
ID int `bun:"id,pk,autoincrement"`
|
|
||||||
SeriesID int `bun:",notnull"`
|
|
||||||
ScheduledTime *time.Time `bun:"scheduled_time"`
|
|
||||||
ProposedByTeamID int `bun:",notnull"`
|
|
||||||
AcceptedByTeamID *int `bun:"accepted_by_team_id"`
|
|
||||||
Status ScheduleStatus `bun:",notnull,default:'pending'"`
|
|
||||||
RescheduleReason *string `bun:"reschedule_reason"`
|
|
||||||
CreatedAt int64 `bun:",notnull"`
|
|
||||||
UpdatedAt *int64 `bun:"updated_at"`
|
|
||||||
|
|
||||||
Series *PlayoffSeries `bun:"rel:belongs-to,join:series_id=id"`
|
|
||||||
ProposedBy *Team `bun:"rel:belongs-to,join:proposed_by_team_id=id"`
|
|
||||||
AcceptedBy *Team `bun:"rel:belongs-to,join:accepted_by_team_id=id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentSeriesSchedule returns the most recent schedule record for a series.
|
|
||||||
// Returns nil, nil if no schedule exists.
|
|
||||||
func GetCurrentSeriesSchedule(ctx context.Context, tx bun.Tx, seriesID int) (*PlayoffSeriesSchedule, error) {
|
|
||||||
schedule := new(PlayoffSeriesSchedule)
|
|
||||||
err := tx.NewSelect().
|
|
||||||
Model(schedule).
|
|
||||||
Where("series_id = ?", seriesID).
|
|
||||||
Order("created_at DESC", "id DESC").
|
|
||||||
Relation("ProposedBy").
|
|
||||||
Relation("AcceptedBy").
|
|
||||||
Limit(1).
|
|
||||||
Scan(ctx)
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "sql: no rows in result set" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
|
||||||
}
|
|
||||||
return schedule, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSeriesScheduleHistory returns all schedule records for a series in chronological order
|
|
||||||
func GetSeriesScheduleHistory(ctx context.Context, tx bun.Tx, seriesID int) ([]*PlayoffSeriesSchedule, error) {
|
|
||||||
schedules, err := GetList[PlayoffSeriesSchedule](tx).
|
|
||||||
Where("series_id = ?", seriesID).
|
|
||||||
Order("created_at ASC", "id ASC").
|
|
||||||
Relation("ProposedBy").
|
|
||||||
Relation("AcceptedBy").
|
|
||||||
GetAll(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "GetList")
|
|
||||||
}
|
|
||||||
return schedules, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProposeSeriesSchedule creates a new pending schedule proposal for a series.
|
|
||||||
// Cannot propose on cancelled or accepted schedules.
|
|
||||||
func ProposeSeriesSchedule(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
seriesID, proposedByTeamID int,
|
|
||||||
scheduledTime time.Time,
|
|
||||||
audit *AuditMeta,
|
|
||||||
) (*PlayoffSeriesSchedule, error) {
|
|
||||||
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "GetCurrentSeriesSchedule")
|
|
||||||
}
|
|
||||||
if current != nil {
|
|
||||||
switch current.Status {
|
|
||||||
case ScheduleStatusCancelled:
|
|
||||||
return nil, BadRequest("cannot propose a new time for a cancelled series")
|
|
||||||
case ScheduleStatusAccepted:
|
|
||||||
return nil, BadRequest("series already has an accepted schedule; use reschedule instead")
|
|
||||||
case ScheduleStatusPending:
|
|
||||||
// Supersede existing pending record
|
|
||||||
now := time.Now().Unix()
|
|
||||||
current.Status = ScheduleStatusRescheduled
|
|
||||||
current.UpdatedAt = &now
|
|
||||||
err = UpdateByID(tx, current.ID, current).
|
|
||||||
Column("status", "updated_at").
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "UpdateByID")
|
|
||||||
}
|
|
||||||
// rejected, rescheduled, postponed, withdrawn are terminal — safe to create a new proposal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule := &PlayoffSeriesSchedule{
|
|
||||||
SeriesID: seriesID,
|
|
||||||
ScheduledTime: &scheduledTime,
|
|
||||||
ProposedByTeamID: proposedByTeamID,
|
|
||||||
Status: ScheduleStatusPending,
|
|
||||||
CreatedAt: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
|
|
||||||
Action: "series_schedule.propose",
|
|
||||||
ResourceType: "playoff_series_schedule",
|
|
||||||
ResourceID: seriesID,
|
|
||||||
Details: map[string]any{
|
|
||||||
"series_id": seriesID,
|
|
||||||
"proposed_by": proposedByTeamID,
|
|
||||||
"scheduled_time": scheduledTime,
|
|
||||||
},
|
|
||||||
}).Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "Insert")
|
|
||||||
}
|
|
||||||
return schedule, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AcceptSeriesSchedule accepts a pending schedule proposal.
|
|
||||||
// The acceptedByTeamID must be the other team (not the proposer).
|
|
||||||
func AcceptSeriesSchedule(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
scheduleID, acceptedByTeamID int,
|
|
||||||
audit *AuditMeta,
|
|
||||||
) error {
|
|
||||||
schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "GetByID")
|
|
||||||
}
|
|
||||||
if schedule.Status != ScheduleStatusPending {
|
|
||||||
return BadRequest("schedule is not in pending status")
|
|
||||||
}
|
|
||||||
if schedule.ProposedByTeamID == acceptedByTeamID {
|
|
||||||
return BadRequest("cannot accept your own proposal")
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
schedule.AcceptedByTeamID = &acceptedByTeamID
|
|
||||||
schedule.Status = ScheduleStatusAccepted
|
|
||||||
schedule.UpdatedAt = &now
|
|
||||||
err = UpdateByID(tx, schedule.ID, schedule).
|
|
||||||
Column("accepted_by_team_id", "status", "updated_at").
|
|
||||||
WithAudit(audit, &AuditInfo{
|
|
||||||
Action: "series_schedule.accept",
|
|
||||||
ResourceType: "playoff_series_schedule",
|
|
||||||
ResourceID: scheduleID,
|
|
||||||
Details: map[string]any{
|
|
||||||
"series_id": schedule.SeriesID,
|
|
||||||
"accepted_by": acceptedByTeamID,
|
|
||||||
},
|
|
||||||
}).Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "UpdateByID")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RejectSeriesSchedule rejects a pending schedule proposal.
|
|
||||||
func RejectSeriesSchedule(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
scheduleID int,
|
|
||||||
audit *AuditMeta,
|
|
||||||
) error {
|
|
||||||
schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "GetByID")
|
|
||||||
}
|
|
||||||
if schedule.Status != ScheduleStatusPending {
|
|
||||||
return BadRequest("schedule is not in pending status")
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
schedule.Status = ScheduleStatusRejected
|
|
||||||
schedule.UpdatedAt = &now
|
|
||||||
err = UpdateByID(tx, schedule.ID, schedule).
|
|
||||||
Column("status", "updated_at").
|
|
||||||
WithAudit(audit, &AuditInfo{
|
|
||||||
Action: "series_schedule.reject",
|
|
||||||
ResourceType: "playoff_series_schedule",
|
|
||||||
ResourceID: scheduleID,
|
|
||||||
Details: map[string]any{
|
|
||||||
"series_id": schedule.SeriesID,
|
|
||||||
},
|
|
||||||
}).Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "UpdateByID")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RescheduleSeriesSchedule marks the current accepted schedule as rescheduled
|
|
||||||
// and creates a new pending proposal with the new time.
|
|
||||||
func RescheduleSeriesSchedule(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
seriesID, proposedByTeamID int,
|
|
||||||
newTime time.Time,
|
|
||||||
reason string,
|
|
||||||
audit *AuditMeta,
|
|
||||||
) (*PlayoffSeriesSchedule, error) {
|
|
||||||
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "GetCurrentSeriesSchedule")
|
|
||||||
}
|
|
||||||
if current == nil || current.Status != ScheduleStatusAccepted {
|
|
||||||
return nil, BadRequest("no accepted schedule to reschedule")
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
current.Status = ScheduleStatusRescheduled
|
|
||||||
current.RescheduleReason = &reason
|
|
||||||
current.UpdatedAt = &now
|
|
||||||
err = UpdateByID(tx, current.ID, current).
|
|
||||||
Column("status", "reschedule_reason", "updated_at").
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "UpdateByID")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new pending proposal
|
|
||||||
schedule := &PlayoffSeriesSchedule{
|
|
||||||
SeriesID: seriesID,
|
|
||||||
ScheduledTime: &newTime,
|
|
||||||
ProposedByTeamID: proposedByTeamID,
|
|
||||||
Status: ScheduleStatusPending,
|
|
||||||
CreatedAt: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
|
|
||||||
Action: "series_schedule.reschedule",
|
|
||||||
ResourceType: "playoff_series_schedule",
|
|
||||||
ResourceID: seriesID,
|
|
||||||
Details: map[string]any{
|
|
||||||
"series_id": seriesID,
|
|
||||||
"proposed_by": proposedByTeamID,
|
|
||||||
"new_time": newTime,
|
|
||||||
"reason": reason,
|
|
||||||
"old_schedule_id": current.ID,
|
|
||||||
},
|
|
||||||
}).Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "Insert")
|
|
||||||
}
|
|
||||||
return schedule, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostponeSeriesSchedule marks the current accepted schedule as postponed.
|
|
||||||
// This is a terminal state — a new proposal can be created afterwards.
|
|
||||||
func PostponeSeriesSchedule(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
seriesID int,
|
|
||||||
reason string,
|
|
||||||
audit *AuditMeta,
|
|
||||||
) error {
|
|
||||||
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "GetCurrentSeriesSchedule")
|
|
||||||
}
|
|
||||||
if current == nil || current.Status != ScheduleStatusAccepted {
|
|
||||||
return BadRequest("no accepted schedule to postpone")
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
current.Status = ScheduleStatusPostponed
|
|
||||||
current.RescheduleReason = &reason
|
|
||||||
current.UpdatedAt = &now
|
|
||||||
err = UpdateByID(tx, current.ID, current).
|
|
||||||
Column("status", "reschedule_reason", "updated_at").
|
|
||||||
WithAudit(audit, &AuditInfo{
|
|
||||||
Action: "series_schedule.postpone",
|
|
||||||
ResourceType: "playoff_series_schedule",
|
|
||||||
ResourceID: seriesID,
|
|
||||||
Details: map[string]any{
|
|
||||||
"series_id": seriesID,
|
|
||||||
"reason": reason,
|
|
||||||
"old_schedule_id": current.ID,
|
|
||||||
},
|
|
||||||
}).Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "UpdateByID")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithdrawSeriesSchedule allows the proposer to withdraw their pending proposal.
|
|
||||||
// Only the team that proposed can withdraw it.
|
|
||||||
func WithdrawSeriesSchedule(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
scheduleID, withdrawByTeamID int,
|
|
||||||
audit *AuditMeta,
|
|
||||||
) error {
|
|
||||||
schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "GetByID")
|
|
||||||
}
|
|
||||||
if schedule.Status != ScheduleStatusPending {
|
|
||||||
return BadRequest("schedule is not in pending status")
|
|
||||||
}
|
|
||||||
if schedule.ProposedByTeamID != withdrawByTeamID {
|
|
||||||
return BadRequest("only the proposing team can withdraw their proposal")
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
schedule.Status = ScheduleStatusWithdrawn
|
|
||||||
schedule.UpdatedAt = &now
|
|
||||||
err = UpdateByID(tx, schedule.ID, schedule).
|
|
||||||
Column("status", "updated_at").
|
|
||||||
WithAudit(audit, &AuditInfo{
|
|
||||||
Action: "series_schedule.withdraw",
|
|
||||||
ResourceType: "playoff_series_schedule",
|
|
||||||
ResourceID: scheduleID,
|
|
||||||
Details: map[string]any{
|
|
||||||
"series_id": schedule.SeriesID,
|
|
||||||
"withdrawn_by": withdrawByTeamID,
|
|
||||||
},
|
|
||||||
}).Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "UpdateByID")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelSeriesSchedule marks the current schedule as cancelled. This is a terminal state.
|
|
||||||
// Requires playoffs.manage permission (moderator-level).
|
|
||||||
func CancelSeriesSchedule(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
seriesID int,
|
|
||||||
reason string,
|
|
||||||
audit *AuditMeta,
|
|
||||||
) error {
|
|
||||||
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "GetCurrentSeriesSchedule")
|
|
||||||
}
|
|
||||||
if current == nil {
|
|
||||||
return BadRequest("no schedule to cancel")
|
|
||||||
}
|
|
||||||
if current.Status.IsTerminal() {
|
|
||||||
return BadRequest("schedule is already in a terminal state")
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
current.Status = ScheduleStatusCancelled
|
|
||||||
current.RescheduleReason = &reason
|
|
||||||
current.UpdatedAt = &now
|
|
||||||
err = UpdateByID(tx, current.ID, current).
|
|
||||||
Column("status", "reschedule_reason", "updated_at").
|
|
||||||
WithAudit(audit, &AuditInfo{
|
|
||||||
Action: "series_schedule.cancel",
|
|
||||||
ResourceType: "playoff_series_schedule",
|
|
||||||
ResourceID: seriesID,
|
|
||||||
Details: map[string]any{
|
|
||||||
"series_id": seriesID,
|
|
||||||
"reason": reason,
|
|
||||||
"schedule_id": current.ID,
|
|
||||||
},
|
|
||||||
}).Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "UpdateByID")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/uptrace/bun"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetPlayoffPlayerStats returns aggregated player stats from playoff fixtures only
|
|
||||||
// (fixtures with round < 0) for a season-league.
|
|
||||||
// Reuses the same LeaguePlayerStats struct as regular season stats.
|
|
||||||
func GetPlayoffPlayerStats(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
seasonID, leagueID int,
|
|
||||||
) ([]*LeaguePlayerStats, error) {
|
|
||||||
if seasonID == 0 {
|
|
||||||
return nil, errors.New("seasonID not provided")
|
|
||||||
}
|
|
||||||
if leagueID == 0 {
|
|
||||||
return nil, errors.New("leagueID not provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats []*LeaguePlayerStats
|
|
||||||
err := tx.NewRaw(`
|
|
||||||
SELECT
|
|
||||||
agg.player_id,
|
|
||||||
agg.player_name,
|
|
||||||
COALESCE(tr.team_id, 0) AS team_id,
|
|
||||||
COALESCE(t.name, '') AS team_name,
|
|
||||||
COALESCE(t.color, '') AS team_color,
|
|
||||||
agg.games_played,
|
|
||||||
agg.total_periods_played,
|
|
||||||
agg.total_goals,
|
|
||||||
agg.total_assists,
|
|
||||||
agg.total_primary_assists,
|
|
||||||
agg.total_secondary_assists,
|
|
||||||
agg.total_saves,
|
|
||||||
agg.total_shots,
|
|
||||||
agg.total_blocks,
|
|
||||||
agg.total_passes,
|
|
||||||
agg.total_score
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
frps.player_id AS player_id,
|
|
||||||
COALESCE(p.name, frps.player_username) AS player_name,
|
|
||||||
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.primary_assists), 0) AS total_primary_assists,
|
|
||||||
COALESCE(SUM(frps.secondary_assists), 0) AS total_secondary_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,
|
|
||||||
COALESCE(SUM(frps.score), 0) AS total_score
|
|
||||||
FROM fixture_result_player_stats frps
|
|
||||||
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
|
||||||
JOIN fixtures f ON f.id = fr.fixture_id
|
|
||||||
LEFT JOIN players p ON p.id = frps.player_id
|
|
||||||
WHERE fr.finalized = true
|
|
||||||
AND f.season_id = ?
|
|
||||||
AND f.league_id = ?
|
|
||||||
AND f.round < 0
|
|
||||||
AND frps.period_num = 3
|
|
||||||
AND frps.player_id IS NOT NULL
|
|
||||||
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
|
||||||
) agg
|
|
||||||
LEFT JOIN team_rosters tr
|
|
||||||
ON tr.player_id = agg.player_id
|
|
||||||
AND tr.season_id = ?
|
|
||||||
AND tr.league_id = ?
|
|
||||||
LEFT JOIN teams t ON t.id = tr.team_id
|
|
||||||
ORDER BY agg.total_score DESC
|
|
||||||
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "tx.NewRaw")
|
|
||||||
}
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPlayoffTopGoalScorers returns the top 10 goal scorers from playoff fixtures.
|
|
||||||
func GetPlayoffTopGoalScorers(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
seasonID, leagueID int,
|
|
||||||
) ([]*LeagueTopGoalScorer, error) {
|
|
||||||
if seasonID == 0 {
|
|
||||||
return nil, errors.New("seasonID not provided")
|
|
||||||
}
|
|
||||||
if leagueID == 0 {
|
|
||||||
return nil, errors.New("leagueID not provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats []*LeagueTopGoalScorer
|
|
||||||
err := tx.NewRaw(`
|
|
||||||
SELECT
|
|
||||||
agg.player_id,
|
|
||||||
agg.player_name,
|
|
||||||
COALESCE(tr.team_id, 0) AS team_id,
|
|
||||||
COALESCE(t.name, '') AS team_name,
|
|
||||||
COALESCE(t.color, '') AS team_color,
|
|
||||||
agg.total_goals,
|
|
||||||
agg.total_periods_played,
|
|
||||||
agg.total_shots
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
frps.player_id AS player_id,
|
|
||||||
COALESCE(p.name, frps.player_username) AS player_name,
|
|
||||||
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
|
||||||
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
|
||||||
COALESCE(SUM(frps.shots), 0) AS total_shots
|
|
||||||
FROM fixture_result_player_stats frps
|
|
||||||
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
|
||||||
JOIN fixtures f ON f.id = fr.fixture_id
|
|
||||||
LEFT JOIN players p ON p.id = frps.player_id
|
|
||||||
WHERE fr.finalized = true
|
|
||||||
AND f.season_id = ?
|
|
||||||
AND f.league_id = ?
|
|
||||||
AND f.round < 0
|
|
||||||
AND frps.period_num = 3
|
|
||||||
AND frps.player_id IS NOT NULL
|
|
||||||
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
|
||||||
ORDER BY total_goals DESC, total_periods_played ASC, total_shots ASC
|
|
||||||
LIMIT 10
|
|
||||||
) agg
|
|
||||||
LEFT JOIN team_rosters tr
|
|
||||||
ON tr.player_id = agg.player_id
|
|
||||||
AND tr.season_id = ?
|
|
||||||
AND tr.league_id = ?
|
|
||||||
LEFT JOIN teams t ON t.id = tr.team_id
|
|
||||||
ORDER BY agg.total_goals DESC, agg.total_periods_played ASC, agg.total_shots ASC
|
|
||||||
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "tx.NewRaw")
|
|
||||||
}
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPlayoffTopAssisters returns the top 10 assisters from playoff fixtures.
|
|
||||||
func GetPlayoffTopAssisters(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
seasonID, leagueID int,
|
|
||||||
) ([]*LeagueTopAssister, error) {
|
|
||||||
if seasonID == 0 {
|
|
||||||
return nil, errors.New("seasonID not provided")
|
|
||||||
}
|
|
||||||
if leagueID == 0 {
|
|
||||||
return nil, errors.New("leagueID not provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats []*LeagueTopAssister
|
|
||||||
err := tx.NewRaw(`
|
|
||||||
SELECT
|
|
||||||
agg.player_id,
|
|
||||||
agg.player_name,
|
|
||||||
COALESCE(tr.team_id, 0) AS team_id,
|
|
||||||
COALESCE(t.name, '') AS team_name,
|
|
||||||
COALESCE(t.color, '') AS team_color,
|
|
||||||
agg.total_assists,
|
|
||||||
agg.total_periods_played,
|
|
||||||
agg.total_primary_assists
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
frps.player_id AS player_id,
|
|
||||||
COALESCE(p.name, frps.player_username) AS player_name,
|
|
||||||
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
|
||||||
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
|
||||||
COALESCE(SUM(frps.primary_assists), 0) AS total_primary_assists
|
|
||||||
FROM fixture_result_player_stats frps
|
|
||||||
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
|
||||||
JOIN fixtures f ON f.id = fr.fixture_id
|
|
||||||
LEFT JOIN players p ON p.id = frps.player_id
|
|
||||||
WHERE fr.finalized = true
|
|
||||||
AND f.season_id = ?
|
|
||||||
AND f.league_id = ?
|
|
||||||
AND f.round < 0
|
|
||||||
AND frps.period_num = 3
|
|
||||||
AND frps.player_id IS NOT NULL
|
|
||||||
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
|
||||||
ORDER BY total_assists DESC, total_periods_played ASC, total_primary_assists DESC
|
|
||||||
LIMIT 10
|
|
||||||
) agg
|
|
||||||
LEFT JOIN team_rosters tr
|
|
||||||
ON tr.player_id = agg.player_id
|
|
||||||
AND tr.season_id = ?
|
|
||||||
AND tr.league_id = ?
|
|
||||||
LEFT JOIN teams t ON t.id = tr.team_id
|
|
||||||
ORDER BY agg.total_assists DESC, agg.total_periods_played ASC, agg.total_primary_assists DESC
|
|
||||||
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "tx.NewRaw")
|
|
||||||
}
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPlayoffTopSavers returns the top 10 savers from playoff fixtures.
|
|
||||||
func GetPlayoffTopSavers(
|
|
||||||
ctx context.Context,
|
|
||||||
tx bun.Tx,
|
|
||||||
seasonID, leagueID int,
|
|
||||||
) ([]*LeagueTopSaver, error) {
|
|
||||||
if seasonID == 0 {
|
|
||||||
return nil, errors.New("seasonID not provided")
|
|
||||||
}
|
|
||||||
if leagueID == 0 {
|
|
||||||
return nil, errors.New("leagueID not provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats []*LeagueTopSaver
|
|
||||||
err := tx.NewRaw(`
|
|
||||||
SELECT
|
|
||||||
agg.player_id,
|
|
||||||
agg.player_name,
|
|
||||||
COALESCE(tr.team_id, 0) AS team_id,
|
|
||||||
COALESCE(t.name, '') AS team_name,
|
|
||||||
COALESCE(t.color, '') AS team_color,
|
|
||||||
agg.total_saves,
|
|
||||||
agg.total_periods_played,
|
|
||||||
agg.total_blocks
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
frps.player_id AS player_id,
|
|
||||||
COALESCE(p.name, frps.player_username) AS player_name,
|
|
||||||
COALESCE(SUM(frps.saves), 0) AS total_saves,
|
|
||||||
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
|
||||||
COALESCE(SUM(frps.blocks), 0) AS total_blocks
|
|
||||||
FROM fixture_result_player_stats frps
|
|
||||||
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
|
||||||
JOIN fixtures f ON f.id = fr.fixture_id
|
|
||||||
LEFT JOIN players p ON p.id = frps.player_id
|
|
||||||
WHERE fr.finalized = true
|
|
||||||
AND f.season_id = ?
|
|
||||||
AND f.league_id = ?
|
|
||||||
AND f.round < 0
|
|
||||||
AND frps.period_num = 3
|
|
||||||
AND frps.player_id IS NOT NULL
|
|
||||||
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
|
||||||
ORDER BY total_saves DESC, total_periods_played ASC, total_blocks DESC
|
|
||||||
LIMIT 10
|
|
||||||
) agg
|
|
||||||
LEFT JOIN team_rosters tr
|
|
||||||
ON tr.player_id = agg.player_id
|
|
||||||
AND tr.season_id = ?
|
|
||||||
AND tr.league_id = ?
|
|
||||||
LEFT JOIN teams t ON t.id = tr.team_id
|
|
||||||
ORDER BY agg.total_saves DESC, agg.total_periods_played ASC, agg.total_blocks DESC
|
|
||||||
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "tx.NewRaw")
|
|
||||||
}
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
@@ -327,9 +327,6 @@
|
|||||||
max-width: 96rem;
|
max-width: 96rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.mx-1 {
|
|
||||||
margin-inline: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
@@ -472,9 +469,6 @@
|
|||||||
.h-9 {
|
.h-9 {
|
||||||
height: calc(var(--spacing) * 9);
|
height: calc(var(--spacing) * 9);
|
||||||
}
|
}
|
||||||
.h-10 {
|
|
||||||
height: calc(var(--spacing) * 10);
|
|
||||||
}
|
|
||||||
.h-12 {
|
.h-12 {
|
||||||
height: calc(var(--spacing) * 12);
|
height: calc(var(--spacing) * 12);
|
||||||
}
|
}
|
||||||
@@ -559,9 +553,6 @@
|
|||||||
.w-14 {
|
.w-14 {
|
||||||
width: calc(var(--spacing) * 14);
|
width: calc(var(--spacing) * 14);
|
||||||
}
|
}
|
||||||
.w-16 {
|
|
||||||
width: calc(var(--spacing) * 16);
|
|
||||||
}
|
|
||||||
.w-20 {
|
.w-20 {
|
||||||
width: calc(var(--spacing) * 20);
|
width: calc(var(--spacing) * 20);
|
||||||
}
|
}
|
||||||
@@ -679,9 +670,6 @@
|
|||||||
--tw-scale-z: 100%;
|
--tw-scale-z: 100%;
|
||||||
scale: var(--tw-scale-x) var(--tw-scale-y);
|
scale: var(--tw-scale-x) var(--tw-scale-y);
|
||||||
}
|
}
|
||||||
.rotate-180 {
|
|
||||||
rotate: 180deg;
|
|
||||||
}
|
|
||||||
.transform {
|
.transform {
|
||||||
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
||||||
}
|
}
|
||||||
@@ -766,9 +754,6 @@
|
|||||||
.justify-end {
|
.justify-end {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
.gap-0 {
|
|
||||||
gap: calc(var(--spacing) * 0);
|
|
||||||
}
|
|
||||||
.gap-0\.5 {
|
.gap-0\.5 {
|
||||||
gap: calc(var(--spacing) * 0.5);
|
gap: calc(var(--spacing) * 0.5);
|
||||||
}
|
}
|
||||||
@@ -1593,11 +1578,6 @@
|
|||||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
}
|
}
|
||||||
.transition-transform {
|
|
||||||
transition-property: transform, translate, scale, rotate;
|
|
||||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
|
||||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
|
||||||
}
|
|
||||||
.duration-150 {
|
.duration-150 {
|
||||||
--tw-duration: 150ms;
|
--tw-duration: 150ms;
|
||||||
transition-duration: 150ms;
|
transition-duration: 150ms;
|
||||||
@@ -2436,16 +2416,6 @@
|
|||||||
gap: calc(var(--spacing) * 12);
|
gap: calc(var(--spacing) * 12);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.lg\:divide-x {
|
|
||||||
@media (width >= 64rem) {
|
|
||||||
:where(& > :not(:last-child)) {
|
|
||||||
--tw-divide-x-reverse: 0;
|
|
||||||
border-inline-style: var(--tw-border-style);
|
|
||||||
border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));
|
|
||||||
border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.lg\:px-8 {
|
.lg\:px-8 {
|
||||||
@media (width >= 64rem) {
|
@media (width >= 64rem) {
|
||||||
padding-inline: calc(var(--spacing) * 8);
|
padding-inline: calc(var(--spacing) * 8);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
// SeasonLeagueFinalsPage renders the finals tab of a season league page.
|
// SeasonLeagueFinalsPage renders the finals tab of a season league page.
|
||||||
// Displays different content based on season status:
|
// Displays different content based on season status:
|
||||||
// - In Progress: "Regular Season in Progress" with optional "Begin Finals" button
|
// - In Progress: "Regular Season in Progress" with optional "Begin Finals" button
|
||||||
// - Finals Soon/Finals/Completed: The playoff bracket + finals stats
|
// - Finals Soon/Finals/Completed: The playoff bracket
|
||||||
func SeasonLeagueFinalsPage(
|
func SeasonLeagueFinalsPage(
|
||||||
s *hws.Server,
|
s *hws.Server,
|
||||||
conn *db.DB,
|
conn *db.DB,
|
||||||
@@ -34,10 +34,6 @@ func SeasonLeagueFinalsPage(
|
|||||||
var season *db.Season
|
var season *db.Season
|
||||||
var league *db.League
|
var league *db.League
|
||||||
var bracket *db.PlayoffBracket
|
var bracket *db.PlayoffBracket
|
||||||
var topGoals []*db.LeagueTopGoalScorer
|
|
||||||
var topAssists []*db.LeagueTopAssister
|
|
||||||
var topSaves []*db.LeagueTopSaver
|
|
||||||
var allStats []*db.LeaguePlayerStats
|
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
@@ -58,35 +54,15 @@ func SeasonLeagueFinalsPage(
|
|||||||
return false, errors.Wrap(err, "db.GetPlayoffBracket")
|
return false, errors.Wrap(err, "db.GetPlayoffBracket")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load playoff stats if bracket exists
|
|
||||||
if bracket != nil {
|
|
||||||
topGoals, err = db.GetPlayoffTopGoalScorers(ctx, tx, season.ID, league.ID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffTopGoalScorers")
|
|
||||||
}
|
|
||||||
topAssists, err = db.GetPlayoffTopAssisters(ctx, tx, season.ID, league.ID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffTopAssisters")
|
|
||||||
}
|
|
||||||
topSaves, err = db.GetPlayoffTopSavers(ctx, tx, season.ID, league.ID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffTopSavers")
|
|
||||||
}
|
|
||||||
allStats, err = db.GetPlayoffPlayerStats(ctx, tx, season.ID, league.ID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffPlayerStats")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league, bracket, topGoals, topAssists, topSaves, allStats), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league, bracket), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueFinals(season, league, bracket, topGoals, topAssists, topSaves, allStats), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFinals(season, league, bracket), s, r, w)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hws"
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/respond"
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/uptrace/bun"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SeriesDetailPage redirects to the default tab (overview)
|
|
||||||
func SeriesDetailPage(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(w, r, fmt.Sprintf("/series/%d/overview", seriesID), http.StatusSeeOther)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeriesDetailOverviewPage renders the overview tab of the series detail page
|
|
||||||
func SeriesDetailOverviewPage(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var series *db.PlayoffSeries
|
|
||||||
var currentSchedule *db.PlayoffSeriesSchedule
|
|
||||||
var canSchedule bool
|
|
||||||
var userTeamID int
|
|
||||||
var rosters map[string][]*db.PlayerWithPlayStatus
|
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
|
||||||
var err error
|
|
||||||
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule")
|
|
||||||
}
|
|
||||||
|
|
||||||
user := db.CurrentUser(ctx)
|
|
||||||
canSchedule, userTeamID, err = db.CanScheduleSeries(ctx, tx, series, user)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
|
||||||
}
|
|
||||||
|
|
||||||
rosters, err = db.GetSeriesTeamRosters(ctx, tx, series)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetSeriesTeamRosters")
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == "GET" {
|
|
||||||
renderSafely(seasonsview.SeriesDetailOverviewPage(
|
|
||||||
series, currentSchedule, canSchedule, userTeamID, rosters,
|
|
||||||
), s, r, w)
|
|
||||||
} else {
|
|
||||||
renderSafely(seasonsview.SeriesDetailOverviewContent(
|
|
||||||
series, currentSchedule, canSchedule, userTeamID, rosters,
|
|
||||||
), s, r, w)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeriesDetailPreviewPage renders the match preview tab of the series detail page
|
|
||||||
func SeriesDetailPreviewPage(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var series *db.PlayoffSeries
|
|
||||||
var currentSchedule *db.PlayoffSeriesSchedule
|
|
||||||
var rosters map[string][]*db.PlayerWithPlayStatus
|
|
||||||
var previewData *db.MatchPreviewData
|
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
|
||||||
var err error
|
|
||||||
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule")
|
|
||||||
}
|
|
||||||
|
|
||||||
rosters, err = db.GetSeriesTeamRosters(ctx, tx, series)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetSeriesTeamRosters")
|
|
||||||
}
|
|
||||||
|
|
||||||
previewData, err = db.ComputeSeriesPreview(ctx, tx, series)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.ComputeSeriesPreview")
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If completed, redirect to analysis instead
|
|
||||||
if series.Status == db.SeriesStatusCompleted {
|
|
||||||
if r.Method == "GET" {
|
|
||||||
http.Redirect(w, r, fmt.Sprintf("/series/%d/analysis", seriesID), http.StatusSeeOther)
|
|
||||||
} else {
|
|
||||||
respond.HXRedirect(w, "/series/%d/analysis", seriesID)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == "GET" {
|
|
||||||
renderSafely(seasonsview.SeriesDetailPreviewPage(
|
|
||||||
series, currentSchedule, rosters, previewData,
|
|
||||||
), s, r, w)
|
|
||||||
} else {
|
|
||||||
renderSafely(seasonsview.SeriesDetailPreviewContent(
|
|
||||||
series, rosters, previewData,
|
|
||||||
), s, r, w)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeriesDetailAnalysisPage renders the match analysis tab of the series detail page
|
|
||||||
func SeriesDetailAnalysisPage(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var series *db.PlayoffSeries
|
|
||||||
var currentSchedule *db.PlayoffSeriesSchedule
|
|
||||||
var rosters map[string][]*db.PlayerWithPlayStatus
|
|
||||||
var previewData *db.MatchPreviewData
|
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
|
||||||
var err error
|
|
||||||
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule")
|
|
||||||
}
|
|
||||||
|
|
||||||
rosters, err = db.GetSeriesTeamRosters(ctx, tx, series)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetSeriesTeamRosters")
|
|
||||||
}
|
|
||||||
|
|
||||||
previewData, err = db.ComputeSeriesPreview(ctx, tx, series)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.ComputeSeriesPreview")
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not completed, redirect to preview instead
|
|
||||||
if series.Status != db.SeriesStatusCompleted {
|
|
||||||
if r.Method == "GET" {
|
|
||||||
http.Redirect(w, r, fmt.Sprintf("/series/%d/preview", seriesID), http.StatusSeeOther)
|
|
||||||
} else {
|
|
||||||
respond.HXRedirect(w, "/series/%d/preview", seriesID)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == "GET" {
|
|
||||||
renderSafely(seasonsview.SeriesDetailAnalysisPage(
|
|
||||||
series, currentSchedule, rosters, previewData,
|
|
||||||
), s, r, w)
|
|
||||||
} else {
|
|
||||||
renderSafely(seasonsview.SeriesDetailAnalysisContent(
|
|
||||||
series, rosters, previewData,
|
|
||||||
), s, r, w)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeriesDetailSchedulePage renders the schedule tab of the series detail page
|
|
||||||
func SeriesDetailSchedulePage(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var series *db.PlayoffSeries
|
|
||||||
var currentSchedule *db.PlayoffSeriesSchedule
|
|
||||||
var history []*db.PlayoffSeriesSchedule
|
|
||||||
var canSchedule bool
|
|
||||||
var userTeamID int
|
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
|
||||||
var err error
|
|
||||||
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSchedule, err = db.GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetCurrentSeriesSchedule")
|
|
||||||
}
|
|
||||||
|
|
||||||
history, err = db.GetSeriesScheduleHistory(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetSeriesScheduleHistory")
|
|
||||||
}
|
|
||||||
|
|
||||||
user := db.CurrentUser(ctx)
|
|
||||||
canSchedule, userTeamID, err = db.CanScheduleSeries(ctx, tx, series, user)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If completed, redirect to overview
|
|
||||||
if series.Status == db.SeriesStatusCompleted {
|
|
||||||
if r.Method == "GET" {
|
|
||||||
http.Redirect(w, r, fmt.Sprintf("/series/%d/overview", seriesID), http.StatusSeeOther)
|
|
||||||
} else {
|
|
||||||
respond.HXRedirect(w, "/series/%d/overview", seriesID)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == "GET" {
|
|
||||||
renderSafely(seasonsview.SeriesDetailSchedulePage(
|
|
||||||
series, currentSchedule, history, canSchedule, userTeamID,
|
|
||||||
), s, r, w)
|
|
||||||
} else {
|
|
||||||
renderSafely(seasonsview.SeriesDetailScheduleContent(
|
|
||||||
series, currentSchedule, history, canSchedule, userTeamID,
|
|
||||||
), s, r, w)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,502 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"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/view/seasonsview"
|
|
||||||
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/uptrace/bun"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SeriesUploadResultPage renders the upload form for series match logs
|
|
||||||
func SeriesUploadResultPage(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var series *db.PlayoffSeries
|
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
|
||||||
var err error
|
|
||||||
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for existing pending results
|
|
||||||
hasPending, err := db.HasPendingSeriesResults(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.HasPendingSeriesResults")
|
|
||||||
}
|
|
||||||
if hasPending {
|
|
||||||
throw.BadRequest(s, w, r, "Pending results already exist for this series. Discard them first to re-upload.", nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSafely(seasonsview.SeriesUploadResultPage(series), s, r, w)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeriesUploadResults handles POST /series/{series_id}/results/upload
|
|
||||||
// Parses match logs for all games, creates fixtures + results.
|
|
||||||
func SeriesUploadResults(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse multipart form
|
|
||||||
err = r.ParseMultipartForm(maxUploadSize * 5) // up to 5 games worth
|
|
||||||
if err != nil {
|
|
||||||
notify.Warn(s, w, r, "Upload Failed", "Could not parse uploaded files.", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
gameCountStr := r.FormValue("game_count")
|
|
||||||
gameCount, err := strconv.Atoi(gameCountStr)
|
|
||||||
if err != nil || gameCount < 1 {
|
|
||||||
notify.Warn(s, w, r, "Invalid Input", "Please select a valid number of games.", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse all game logs
|
|
||||||
type gameLogs struct {
|
|
||||||
Logs [3]*slapshotapi.MatchLog
|
|
||||||
}
|
|
||||||
allGameLogs := make([]*gameLogs, gameCount)
|
|
||||||
|
|
||||||
for g := 1; g <= gameCount; g++ {
|
|
||||||
gl := &gameLogs{}
|
|
||||||
for p := 1; p <= 3; p++ {
|
|
||||||
fieldName := "game_" + strconv.Itoa(g) + "_period_" + strconv.Itoa(p)
|
|
||||||
file, _, err := r.FormFile(fieldName)
|
|
||||||
if err != nil {
|
|
||||||
notify.Warn(s, w, r, "Missing File",
|
|
||||||
"All 3 period files are required for Game "+strconv.Itoa(g)+". Missing period "+strconv.Itoa(p)+".", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer func() { _ = file.Close() }()
|
|
||||||
|
|
||||||
data, err := io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
notify.Warn(s, w, r, "Read Error", "Could not read file: "+fieldName, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log, err := slapshotapi.ParseMatchLog(data)
|
|
||||||
if err != nil {
|
|
||||||
notify.Warn(s, w, r, "Parse Error",
|
|
||||||
"Could not parse Game "+strconv.Itoa(g)+" Period "+strconv.Itoa(p)+": "+err.Error(), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gl.Logs[p-1] = log
|
|
||||||
}
|
|
||||||
allGameLogs[g-1] = gl
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
respond.NotFound(w, errors.New("series not found"))
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate game count
|
|
||||||
maxGames := series.MatchesToWin*2 - 1
|
|
||||||
if gameCount < series.MatchesToWin || gameCount > maxGames {
|
|
||||||
notify.Warn(s, w, r, "Invalid Game Count",
|
|
||||||
"Game count must be between "+strconv.Itoa(series.MatchesToWin)+" and "+strconv.Itoa(maxGames)+".", nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for existing pending results
|
|
||||||
hasPending, err := db.HasPendingSeriesResults(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.HasPendingSeriesResults")
|
|
||||||
}
|
|
||||||
if hasPending {
|
|
||||||
notify.Warn(s, w, r, "Results Exist", "Pending results already exist. Discard them first.", nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
audit := db.NewAuditFromRequest(r)
|
|
||||||
user := db.CurrentUser(ctx)
|
|
||||||
|
|
||||||
// Process each game
|
|
||||||
team1Wins := 0
|
|
||||||
team2Wins := 0
|
|
||||||
|
|
||||||
for g := 0; g < gameCount; g++ {
|
|
||||||
gl := allGameLogs[g]
|
|
||||||
logs := []*slapshotapi.MatchLog{gl.Logs[0], gl.Logs[1], gl.Logs[2]}
|
|
||||||
matchNumber := g + 1
|
|
||||||
|
|
||||||
// Check if series is already decided
|
|
||||||
if team1Wins >= series.MatchesToWin || team2Wins >= series.MatchesToWin {
|
|
||||||
notify.Warn(s, w, r, "Too Many Games",
|
|
||||||
"The series was already decided before Game "+strconv.Itoa(matchNumber)+". Reduce the game count.", nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect tampering
|
|
||||||
tamperingDetected, tamperingReason, err := slapshotapi.DetectTampering(logs)
|
|
||||||
if err != nil {
|
|
||||||
notify.Warn(s, w, r, "Validation Error",
|
|
||||||
"Game "+strconv.Itoa(matchNumber)+" tampering check failed: "+err.Error(), nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create fixture for this game
|
|
||||||
fixture, _, err := db.CreatePlayoffGameFixture(ctx, tx, series, matchNumber, audit)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.CreatePlayoffGameFixture")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect game_user_ids
|
|
||||||
gameUserIDSet := map[string]bool{}
|
|
||||||
for _, log := range logs {
|
|
||||||
for _, p := range log.Players {
|
|
||||||
gameUserIDSet[p.GameUserID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gameUserIDs := make([]string, 0, len(gameUserIDSet))
|
|
||||||
for id := range gameUserIDSet {
|
|
||||||
gameUserIDs = append(gameUserIDs, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map players
|
|
||||||
playerLookup, err := MapGameUserIDsToPlayers(ctx, tx, gameUserIDs, fixture.SeasonID, fixture.LeagueID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "MapGameUserIDsToPlayers")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine orientation
|
|
||||||
allPlayers := logs[2].Players
|
|
||||||
fixtureHomeIsLogsHome, _, err := DetermineTeamOrientation(ctx, tx, fixture, allPlayers, playerLookup)
|
|
||||||
if err != nil {
|
|
||||||
notify.Warn(s, w, r, "Orientation Error",
|
|
||||||
"Game "+strconv.Itoa(matchNumber)+": Could not determine team orientation: "+err.Error(), nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build result
|
|
||||||
finalLog := logs[2]
|
|
||||||
winner := finalLog.Winner
|
|
||||||
homeScore := finalLog.Score.Home
|
|
||||||
awayScore := finalLog.Score.Away
|
|
||||||
if !fixtureHomeIsLogsHome {
|
|
||||||
switch winner {
|
|
||||||
case "home":
|
|
||||||
winner = "away"
|
|
||||||
case "away":
|
|
||||||
winner = "home"
|
|
||||||
}
|
|
||||||
homeScore, awayScore = awayScore, homeScore
|
|
||||||
}
|
|
||||||
|
|
||||||
periodsEnabled := finalLog.PeriodsEnabled == "True"
|
|
||||||
customMercyRule, _ := strconv.Atoi(finalLog.CustomMercyRule)
|
|
||||||
matchLength, _ := strconv.Atoi(finalLog.MatchLength)
|
|
||||||
|
|
||||||
var tamperingReasonPtr *string
|
|
||||||
if tamperingDetected {
|
|
||||||
tamperingReasonPtr = &tamperingReason
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &db.FixtureResult{
|
|
||||||
FixtureID: fixture.ID,
|
|
||||||
Winner: winner,
|
|
||||||
HomeScore: homeScore,
|
|
||||||
AwayScore: awayScore,
|
|
||||||
MatchType: finalLog.Type,
|
|
||||||
Arena: finalLog.Arena,
|
|
||||||
EndReason: finalLog.EndReason,
|
|
||||||
PeriodsEnabled: periodsEnabled,
|
|
||||||
CustomMercyRule: customMercyRule,
|
|
||||||
MatchLength: matchLength,
|
|
||||||
UploadedByUserID: user.ID,
|
|
||||||
Finalized: false,
|
|
||||||
TamperingDetected: tamperingDetected,
|
|
||||||
TamperingReason: tamperingReasonPtr,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build player stats
|
|
||||||
playerStats := []*db.FixtureResultPlayerStats{}
|
|
||||||
for periodIdx, log := range logs {
|
|
||||||
periodNum := periodIdx + 1
|
|
||||||
for _, p := range log.Players {
|
|
||||||
team := p.Team
|
|
||||||
if !fixtureHomeIsLogsHome {
|
|
||||||
if team == "home" {
|
|
||||||
team = "away"
|
|
||||||
} else {
|
|
||||||
team = "home"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var playerID *int
|
|
||||||
var teamID *int
|
|
||||||
if lookup, ok := playerLookup[p.GameUserID]; ok && lookup.Found {
|
|
||||||
playerID = &lookup.Player.ID
|
|
||||||
if !lookup.Unmapped {
|
|
||||||
teamID = &lookup.TeamID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stat := &db.FixtureResultPlayerStats{
|
|
||||||
PeriodNum: periodNum,
|
|
||||||
PlayerID: playerID,
|
|
||||||
PlayerGameUserID: p.GameUserID,
|
|
||||||
PlayerUsername: p.Username,
|
|
||||||
TeamID: teamID,
|
|
||||||
Team: team,
|
|
||||||
Goals: FloatToIntPtr(p.Stats.Goals),
|
|
||||||
Assists: FloatToIntPtr(p.Stats.Assists),
|
|
||||||
PrimaryAssists: FloatToIntPtr(p.Stats.PrimaryAssists),
|
|
||||||
SecondaryAssists: FloatToIntPtr(p.Stats.SecondaryAssists),
|
|
||||||
Saves: FloatToIntPtr(p.Stats.Saves),
|
|
||||||
Blocks: FloatToIntPtr(p.Stats.Blocks),
|
|
||||||
Shots: FloatToIntPtr(p.Stats.Shots),
|
|
||||||
Turnovers: FloatToIntPtr(p.Stats.Turnovers),
|
|
||||||
Takeaways: FloatToIntPtr(p.Stats.Takeaways),
|
|
||||||
Passes: FloatToIntPtr(p.Stats.Passes),
|
|
||||||
PossessionTimeSec: FloatToIntPtr(p.Stats.PossessionTime),
|
|
||||||
FaceoffsWon: FloatToIntPtr(p.Stats.FaceoffsWon),
|
|
||||||
FaceoffsLost: FloatToIntPtr(p.Stats.FaceoffsLost),
|
|
||||||
PostHits: FloatToIntPtr(p.Stats.PostHits),
|
|
||||||
OvertimeGoals: FloatToIntPtr(p.Stats.OvertimeGoals),
|
|
||||||
GameWinningGoals: FloatToIntPtr(p.Stats.GameWinningGoals),
|
|
||||||
Score: FloatToIntPtr(p.Stats.Score),
|
|
||||||
ContributedGoals: FloatToIntPtr(p.Stats.ContributedGoals),
|
|
||||||
ConcededGoals: FloatToIntPtr(p.Stats.ConcededGoals),
|
|
||||||
GamesPlayed: FloatToIntPtr(p.Stats.GamesPlayed),
|
|
||||||
Wins: FloatToIntPtr(p.Stats.Wins),
|
|
||||||
Losses: FloatToIntPtr(p.Stats.Losses),
|
|
||||||
OvertimeWins: FloatToIntPtr(p.Stats.OvertimeWins),
|
|
||||||
OvertimeLosses: FloatToIntPtr(p.Stats.OvertimeLosses),
|
|
||||||
Ties: FloatToIntPtr(p.Stats.Ties),
|
|
||||||
Shutouts: FloatToIntPtr(p.Stats.Shutouts),
|
|
||||||
ShutoutsAgainst: FloatToIntPtr(p.Stats.ShutsAgainst),
|
|
||||||
HasMercyRuled: FloatToIntPtr(p.Stats.HasMercyRuled),
|
|
||||||
WasMercyRuled: FloatToIntPtr(p.Stats.WasMercyRuled),
|
|
||||||
PeriodsPlayed: FloatToIntPtr(p.Stats.PeriodsPlayed),
|
|
||||||
}
|
|
||||||
playerStats = append(playerStats, stat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark free agents
|
|
||||||
for _, ps := range playerStats {
|
|
||||||
if ps.PlayerID == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
isFA, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, *ps.PlayerID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
|
|
||||||
}
|
|
||||||
if isFA {
|
|
||||||
ps.IsFreeAgent = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert result
|
|
||||||
_, err = db.InsertFixtureResult(ctx, tx, result, playerStats, audit)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.InsertFixtureResult")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track wins: home = team1, away = team2
|
|
||||||
if winner == "home" {
|
|
||||||
team1Wins++
|
|
||||||
} else {
|
|
||||||
team2Wins++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that the series result is valid
|
|
||||||
if team1Wins < series.MatchesToWin && team2Wins < series.MatchesToWin {
|
|
||||||
notify.Warn(s, w, r, "Incomplete Series",
|
|
||||||
"Neither team has enough wins to decide the series. More games are needed.", nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respond.HXRedirect(w, "/series/%d/results/review", seriesID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeriesReviewResults handles GET /series/{series_id}/results/review
|
|
||||||
func SeriesReviewResults(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var series *db.PlayoffSeries
|
|
||||||
var gameResults []*seasonsview.SeriesGameResult
|
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
|
||||||
var err error
|
|
||||||
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build game results from matches
|
|
||||||
for _, match := range series.Matches {
|
|
||||||
if match.FixtureID == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := db.GetPendingFixtureResult(ctx, tx, *match.FixtureID)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.GetPendingFixtureResult")
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
gr := &seasonsview.SeriesGameResult{
|
|
||||||
GameNumber: match.MatchNumber,
|
|
||||||
Result: result,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build unmapped players and FA warnings
|
|
||||||
for _, ps := range result.PlayerStats {
|
|
||||||
if ps.PeriodNum != 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ps.PlayerID == nil {
|
|
||||||
gr.UnmappedPlayers = append(gr.UnmappedPlayers,
|
|
||||||
ps.PlayerGameUserID+" ("+ps.PlayerUsername+")")
|
|
||||||
} else if ps.IsFreeAgent {
|
|
||||||
gr.FreeAgentWarnings = append(gr.FreeAgentWarnings, seasonsview.FreeAgentWarning{
|
|
||||||
Name: ps.PlayerUsername,
|
|
||||||
Reason: "free agent in playoff match",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
gameResults = append(gameResults, gr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(gameResults) == 0 {
|
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSafely(seasonsview.SeriesReviewResultPage(series, gameResults), s, r, w)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeriesFinalizeResults handles POST /series/{series_id}/results/finalize
|
|
||||||
func SeriesFinalizeResults(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
|
||||||
_, err := db.FinalizeSeriesResults(ctx, tx, seriesID, db.NewAuditFromRequest(r))
|
|
||||||
if err != nil {
|
|
||||||
if db.IsBadRequest(err) {
|
|
||||||
notify.Warn(s, w, r, "Cannot Finalize", err.Error(), nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.Wrap(err, "db.FinalizeSeriesResults")
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notify.SuccessWithDelay(s, w, r, "Series Finalized", "All game results have been finalized and the series is complete.", nil)
|
|
||||||
respond.HXRedirect(w, "/series/%d", seriesID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeriesDiscardResults handles POST /series/{series_id}/results/discard
|
|
||||||
func SeriesDiscardResults(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
|
||||||
err := db.DeleteSeriesResults(ctx, tx, seriesID, db.NewAuditFromRequest(r))
|
|
||||||
if err != nil {
|
|
||||||
if db.IsBadRequest(err) {
|
|
||||||
notify.Warn(s, w, r, "Cannot Discard", err.Error(), nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.Wrap(err, "db.DeleteSeriesResults")
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notify.Success(s, w, r, "Results Discarded", "All uploaded results have been discarded. You can upload new logs.", nil)
|
|
||||||
respond.HXRedirect(w, "/series/%d", seriesID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
"git.haelnorr.com/h/timefmt"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/uptrace/bun"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProposeSeriesSchedule handles POST /series/{series_id}/schedule
|
|
||||||
func ProposeSeriesSchedule(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
|
|
||||||
DayNumeric2().T().Hour24().Colon().Minute().Build()
|
|
||||||
aest, _ := time.LoadLocation("Australia/Sydney")
|
|
||||||
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).Value
|
|
||||||
|
|
||||||
if !getter.ValidateAndNotify(s, w, r) {
|
|
||||||
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 {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
respond.NotFound(w, errors.New("series not found"))
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user := db.CurrentUser(ctx)
|
|
||||||
canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
|
||||||
}
|
|
||||||
if !canSchedule {
|
|
||||||
throw.Forbidden(s, w, r, "You must be a team manager to propose a schedule", nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ProposeSeriesSchedule(ctx, tx, seriesID, userTeamID, scheduledTime, db.NewAuditFromRequest(r))
|
|
||||||
if err != nil {
|
|
||||||
if db.IsBadRequest(err) {
|
|
||||||
notify.Warn(s, w, r, "Cannot Propose", err.Error(), nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.Wrap(err, "db.ProposeSeriesSchedule")
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notify.SuccessWithDelay(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil)
|
|
||||||
respond.HXRedirect(w, "/series/%d", seriesID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AcceptSeriesSchedule handles POST /series/{series_id}/schedule/{schedule_id}/accept
|
|
||||||
func AcceptSeriesSchedule(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
|
|
||||||
if err != nil {
|
|
||||||
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
|
|
||||||
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 {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
respond.NotFound(w, errors.New("series not found"))
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user := db.CurrentUser(ctx)
|
|
||||||
canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
|
||||||
}
|
|
||||||
if !canSchedule {
|
|
||||||
throw.Forbidden(s, w, r, "You must be a team manager to accept a schedule", nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.AcceptSeriesSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r))
|
|
||||||
if err != nil {
|
|
||||||
if db.IsBadRequest(err) {
|
|
||||||
notify.Warn(s, w, r, "Cannot Accept", err.Error(), nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.Wrap(err, "db.AcceptSeriesSchedule")
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notify.Success(s, w, r, "Schedule Accepted", "The series time has been confirmed.", nil)
|
|
||||||
respond.HXRedirect(w, "/series/%d", seriesID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RejectSeriesSchedule handles POST /series/{series_id}/schedule/{schedule_id}/reject
|
|
||||||
func RejectSeriesSchedule(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
|
|
||||||
if err != nil {
|
|
||||||
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
|
|
||||||
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 {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
respond.NotFound(w, errors.New("series not found"))
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user := db.CurrentUser(ctx)
|
|
||||||
canSchedule, _, err := db.CanScheduleSeries(ctx, tx, series, user)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
|
||||||
}
|
|
||||||
if !canSchedule {
|
|
||||||
throw.Forbidden(s, w, r, "You must be a team manager to reject a schedule", nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.RejectSeriesSchedule(ctx, tx, scheduleID, db.NewAuditFromRequest(r))
|
|
||||||
if err != nil {
|
|
||||||
if db.IsBadRequest(err) {
|
|
||||||
notify.Warn(s, w, r, "Cannot Reject", err.Error(), nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.Wrap(err, "db.RejectSeriesSchedule")
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notify.Success(s, w, r, "Schedule Rejected", "The proposed time has been rejected.", nil)
|
|
||||||
respond.HXRedirect(w, "/series/%d", seriesID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostponeSeriesSchedule handles POST /series/{series_id}/schedule/postpone
|
|
||||||
func PostponeSeriesSchedule(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
|
|
||||||
if !getter.ValidateAndNotify(s, w, r) {
|
|
||||||
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 {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
respond.NotFound(w, errors.New("series not found"))
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user := db.CurrentUser(ctx)
|
|
||||||
canSchedule, _, err := db.CanScheduleSeries(ctx, tx, series, user)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
|
||||||
}
|
|
||||||
if !canSchedule {
|
|
||||||
throw.Forbidden(s, w, r, "You must be a team manager to postpone a series", nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.PostponeSeriesSchedule(ctx, tx, seriesID, reason, db.NewAuditFromRequest(r))
|
|
||||||
if err != nil {
|
|
||||||
if db.IsBadRequest(err) {
|
|
||||||
notify.Warn(s, w, r, "Cannot Postpone", err.Error(), nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.Wrap(err, "db.PostponeSeriesSchedule")
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notify.Success(s, w, r, "Series Postponed", "The series has been postponed.", nil)
|
|
||||||
respond.HXRedirect(w, "/series/%d", seriesID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RescheduleSeriesHandler handles POST /series/{series_id}/schedule/reschedule
|
|
||||||
func RescheduleSeriesHandler(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
|
|
||||||
DayNumeric2().T().Hour24().Colon().Minute().Build()
|
|
||||||
aest, _ := time.LoadLocation("Australia/Sydney")
|
|
||||||
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value
|
|
||||||
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
|
|
||||||
|
|
||||||
if !getter.ValidateAndNotify(s, w, r) {
|
|
||||||
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 {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
respond.NotFound(w, errors.New("series not found"))
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user := db.CurrentUser(ctx)
|
|
||||||
canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
|
||||||
}
|
|
||||||
if !canSchedule {
|
|
||||||
throw.Forbidden(s, w, r, "You must be a team manager to reschedule a series", nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.RescheduleSeriesSchedule(ctx, tx, seriesID, userTeamID, scheduledTime, reason, db.NewAuditFromRequest(r))
|
|
||||||
if err != nil {
|
|
||||||
if db.IsBadRequest(err) {
|
|
||||||
notify.Warn(s, w, r, "Cannot Reschedule", err.Error(), nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.Wrap(err, "db.RescheduleSeriesSchedule")
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notify.Success(s, w, r, "Series Rescheduled", "The new proposed time has been submitted.", nil)
|
|
||||||
respond.HXRedirect(w, "/series/%d", seriesID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithdrawSeriesScheduleHandler handles POST /series/{series_id}/schedule/{schedule_id}/withdraw
|
|
||||||
func WithdrawSeriesScheduleHandler(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
|
|
||||||
if err != nil {
|
|
||||||
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
|
|
||||||
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 {
|
|
||||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
|
||||||
}
|
|
||||||
if series == nil {
|
|
||||||
respond.NotFound(w, errors.New("series not found"))
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user := db.CurrentUser(ctx)
|
|
||||||
canSchedule, userTeamID, err := db.CanScheduleSeries(ctx, tx, series, user)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "db.CanScheduleSeries")
|
|
||||||
}
|
|
||||||
if !canSchedule {
|
|
||||||
throw.Forbidden(s, w, r, "You must be a team manager to withdraw a proposal", nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.WithdrawSeriesSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r))
|
|
||||||
if err != nil {
|
|
||||||
if db.IsBadRequest(err) {
|
|
||||||
notify.Warn(s, w, r, "Cannot Withdraw", err.Error(), nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.Wrap(err, "db.WithdrawSeriesSchedule")
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notify.Success(s, w, r, "Proposal Withdrawn", "Your proposed time has been withdrawn.", nil)
|
|
||||||
respond.HXRedirect(w, "/series/%d", seriesID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelSeriesScheduleHandler handles POST /series/{series_id}/schedule/cancel
|
|
||||||
// This is a moderator-only action that requires playoffs.manage permission.
|
|
||||||
func CancelSeriesScheduleHandler(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
|
|
||||||
if !getter.ValidateAndNotify(s, w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
|
||||||
err := db.CancelSeriesSchedule(ctx, tx, seriesID, reason, db.NewAuditFromRequest(r))
|
|
||||||
if err != nil {
|
|
||||||
if db.IsBadRequest(err) {
|
|
||||||
notify.Warn(s, w, r, "Cannot Cancel", err.Error(), nil)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.Wrap(err, "db.CancelSeriesSchedule")
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notify.Success(s, w, r, "Schedule Cancelled", "The series schedule has been cancelled.", nil)
|
|
||||||
respond.HXRedirect(w, "/series/%d", seriesID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -124,9 +124,6 @@ func refreshToken(
|
|||||||
case "expired", "expiring":
|
case "expired", "expiring":
|
||||||
newtoken, err := discordAPI.RefreshToken(token.Convert())
|
newtoken, err := discordAPI.RefreshToken(token.Convert())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "invalid_grant") {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.Wrap(err, "discordAPI.RefreshToken")
|
return false, errors.Wrap(err, "discordAPI.RefreshToken")
|
||||||
}
|
}
|
||||||
err = user.UpdateDiscordToken(ctx, tx, newtoken)
|
err = user.UpdateDiscordToken(ctx, tx, newtoken)
|
||||||
|
|||||||
@@ -329,94 +329,6 @@ func addRoutes(
|
|||||||
Method: hws.MethodPOST,
|
Method: hws.MethodPOST,
|
||||||
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.ForfeitFixture(s, conn)),
|
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.ForfeitFixture(s, conn)),
|
||||||
},
|
},
|
||||||
// Series detail page routes
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}",
|
|
||||||
Method: hws.MethodGET,
|
|
||||||
Handler: handlers.SeriesDetailPage(s, conn),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/overview",
|
|
||||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
|
||||||
Handler: handlers.SeriesDetailOverviewPage(s, conn),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/preview",
|
|
||||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
|
||||||
Handler: handlers.SeriesDetailPreviewPage(s, conn),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/analysis",
|
|
||||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
|
||||||
Handler: handlers.SeriesDetailAnalysisPage(s, conn),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/scheduling",
|
|
||||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
|
||||||
Handler: handlers.SeriesDetailSchedulePage(s, conn),
|
|
||||||
},
|
|
||||||
// Series scheduling routes
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/schedule",
|
|
||||||
Method: hws.MethodPOST,
|
|
||||||
Handler: handlers.ProposeSeriesSchedule(s, conn),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/schedule/{schedule_id}/accept",
|
|
||||||
Method: hws.MethodPOST,
|
|
||||||
Handler: handlers.AcceptSeriesSchedule(s, conn),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/schedule/{schedule_id}/reject",
|
|
||||||
Method: hws.MethodPOST,
|
|
||||||
Handler: handlers.RejectSeriesSchedule(s, conn),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/schedule/{schedule_id}/withdraw",
|
|
||||||
Method: hws.MethodPOST,
|
|
||||||
Handler: handlers.WithdrawSeriesScheduleHandler(s, conn),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/schedule/postpone",
|
|
||||||
Method: hws.MethodPOST,
|
|
||||||
Handler: handlers.PostponeSeriesSchedule(s, conn),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/schedule/reschedule",
|
|
||||||
Method: hws.MethodPOST,
|
|
||||||
Handler: handlers.RescheduleSeriesHandler(s, conn),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/schedule/cancel",
|
|
||||||
Method: hws.MethodPOST,
|
|
||||||
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.CancelSeriesScheduleHandler(s, conn)),
|
|
||||||
},
|
|
||||||
// Series result management routes
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/results/upload",
|
|
||||||
Method: hws.MethodGET,
|
|
||||||
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesUploadResultPage(s, conn)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/results/upload",
|
|
||||||
Method: hws.MethodPOST,
|
|
||||||
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesUploadResults(s, conn)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/results/review",
|
|
||||||
Method: hws.MethodGET,
|
|
||||||
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesReviewResults(s, conn)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/results/finalize",
|
|
||||||
Method: hws.MethodPOST,
|
|
||||||
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesFinalizeResults(s, conn)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "/series/{series_id}/results/discard",
|
|
||||||
Method: hws.MethodPOST,
|
|
||||||
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesDiscardResults(s, conn)),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
playerRoutes := []hws.Route{
|
playerRoutes := []hws.Route{
|
||||||
|
|||||||
@@ -520,14 +520,6 @@ templ fixtureResultDisplay(fixture *db.Fixture, result *db.FixtureResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ fixtureUploadPrompt(fixture *db.Fixture) {
|
templ fixtureUploadPrompt(fixture *db.Fixture) {
|
||||||
<div
|
|
||||||
x-data="{
|
|
||||||
open: false,
|
|
||||||
forfeitType: 'outright',
|
|
||||||
forfeitTeam: '',
|
|
||||||
forfeitReason: '',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||||||
<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 Result Uploaded</p>
|
<p class="text-lg text-text font-medium mb-2">No Result Uploaded</p>
|
||||||
@@ -542,7 +534,7 @@ templ fixtureUploadPrompt(fixture *db.Fixture) {
|
|||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="open = true"
|
@click="$dispatch('open-forfeit-modal')"
|
||||||
class="inline-block px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
class="inline-block px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||||
font-medium transition hover:cursor-pointer"
|
font-medium transition hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -551,11 +543,17 @@ templ fixtureUploadPrompt(fixture *db.Fixture) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@forfeitModal(fixture)
|
@forfeitModal(fixture)
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
templ forfeitModal(fixture *db.Fixture) {
|
templ forfeitModal(fixture *db.Fixture) {
|
||||||
<div
|
<div
|
||||||
|
x-data="{
|
||||||
|
open: false,
|
||||||
|
forfeitType: 'outright',
|
||||||
|
forfeitTeam: '',
|
||||||
|
forfeitReason: '',
|
||||||
|
}"
|
||||||
|
@open-forfeit-modal.window="open = true"
|
||||||
x-show="open"
|
x-show="open"
|
||||||
x-cloak
|
x-cloak
|
||||||
class="fixed inset-0 z-50 overflow-y-auto"
|
class="fixed inset-0 z-50 overflow-y-auto"
|
||||||
@@ -1205,7 +1203,7 @@ templ fixtureScheduleStatus(
|
|||||||
<div class="flex justify-center mt-4">
|
<div class="flex justify-center mt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={ templ.JSUnsafeFuncCall("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Withdraw Proposal', message: 'Are you sure you want to withdraw your proposed time?', action: () => htmx.ajax('POST', '/fixtures/" + fmt.Sprint(fixture.ID) + "/schedule/" + fmt.Sprint(current.ID) + "/withdraw', { swap: 'none' }) } }))") }
|
@click={ "window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Withdraw Proposal', message: 'Are you sure you want to withdraw your proposed time?', action: () => htmx.ajax('POST', '/fixtures/" + fmt.Sprint(fixture.ID) + "/schedule/" + fmt.Sprint(current.ID) + "/withdraw', { swap: 'none' }) } }))" }
|
||||||
class="px-4 py-2 bg-surface1 hover:bg-surface2 text-text rounded-lg
|
class="px-4 py-2 bg-surface1 hover:bg-surface2 text-text rounded-lg
|
||||||
font-medium transition hover:cursor-pointer"
|
font-medium transition hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -1432,7 +1430,7 @@ templ fixtureScheduleActions(
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("if (!this.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Postpone Fixture', message: 'Are you sure you want to postpone this fixture? The current schedule will be cleared.', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/postpone', { swap: 'none', values: { reschedule_reason: this.parentElement.querySelector('select').value } }) } }))", fixture.ID)) }
|
@click={ fmt.Sprintf("if (!$el.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Postpone Fixture', message: 'Are you sure you want to postpone this fixture? The current schedule will be cleared.', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/postpone', { swap: 'none', values: { reschedule_reason: $el.parentElement.querySelector('select').value } }) } }))", fixture.ID) }
|
||||||
class="w-full px-4 py-2 bg-peach hover:bg-peach/80 text-mantle rounded-lg
|
class="w-full px-4 py-2 bg-peach hover:bg-peach/80 text-mantle rounded-lg
|
||||||
font-medium transition hover:cursor-pointer"
|
font-medium transition hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -1459,7 +1457,7 @@ templ fixtureScheduleActions(
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("if (!this.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Declare Forfeit', message: 'This action is IRREVERSIBLE. The fixture schedule will be permanently cancelled. Are you sure?', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/cancel', { swap: 'none', values: { reschedule_reason: this.parentElement.querySelector('select').value } }) } }))", fixture.ID)) }
|
@click={ fmt.Sprintf("if (!$el.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Declare Forfeit', message: 'This action is IRREVERSIBLE. The fixture schedule will be permanently cancelled. Are you sure?', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/cancel', { swap: 'none', values: { reschedule_reason: $el.parentElement.querySelector('select').value } }) } }))", fixture.ID) }
|
||||||
class="w-full px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
class="w-full px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||||
font-medium transition hover:cursor-pointer"
|
font-medium transition hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ templ FixtureReviewResultPage(
|
|||||||
</form>
|
</form>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Discard Result', message: 'Are you sure you want to discard this result? You will need to re-upload the match logs.', action: () => htmx.ajax('POST', '/fixtures/%d/results/discard', { swap: 'none' }) } }))", fixture.ID)) }
|
@click={ fmt.Sprintf("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Discard Result', message: 'Are you sure you want to discard this result? You will need to re-upload the match logs.', action: () => htmx.ajax('POST', '/fixtures/%d/results/discard', { swap: 'none' }) } }))", fixture.ID) }
|
||||||
class="px-6 py-3 bg-red hover:bg-red/80 text-mantle rounded-lg
|
class="px-6 py-3 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||||
font-medium transition hover:cursor-pointer text-lg"
|
font-medium transition hover:cursor-pointer text-lg"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ templ LeaguesSection(season *db.Season, allLeagues []*db.League) {
|
|||||||
if canRemoveLeague {
|
if canRemoveLeague {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={ templ.JSUnsafeFuncCall("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Remove League', message: 'Are you sure you want to remove " + league.Name + " from this season?', action: () => htmx.ajax('DELETE', '/seasons/" + season.ShortName + "/leagues/" + league.ShortName + "', { target: '#leagues-section', swap: 'outerHTML' }) } }))") }
|
@click={ "window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Remove League', message: 'Are you sure you want to remove " + league.Name + " from this season?', action: () => htmx.ajax('DELETE', '/seasons/" + season.ShortName + "/leagues/" + league.ShortName + "', { target: '#leagues-section', swap: 'outerHTML' }) } }))" }
|
||||||
class="text-red hover:text-red/75 hover:cursor-pointer ml-1"
|
class="text-red hover:text-red/75 hover:cursor-pointer ml-1"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -44,15 +44,6 @@ templ PlayoffBracketView(season *db.Season, league *db.League, bracket *db.Playo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/js/bracket-lines.js"></script>
|
<script src="/static/js/bracket-lines.js"></script>
|
||||||
<script>
|
|
||||||
document.querySelectorAll('[data-series-url]').forEach(function(card) {
|
|
||||||
card.addEventListener('click', function(e) {
|
|
||||||
if (!e.target.closest('a')) {
|
|
||||||
window.location.href = card.getAttribute('data-series-url');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@@ -198,19 +189,11 @@ templ bracket10to15(season *db.Season, league *db.League, bracket *db.PlayoffBra
|
|||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) {
|
templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) {
|
||||||
{{
|
|
||||||
hasTeams := series.Team1 != nil || series.Team2 != nil
|
|
||||||
seriesURL := fmt.Sprintf("/series/%d", series.ID)
|
|
||||||
}}
|
|
||||||
<div
|
<div
|
||||||
data-series={ fmt.Sprint(series.SeriesNumber) }
|
data-series={ fmt.Sprint(series.SeriesNumber) }
|
||||||
if hasTeams {
|
|
||||||
data-series-url={ seriesURL }
|
|
||||||
}
|
|
||||||
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-surface1", series.Status != db.SeriesStatusInProgress) }
|
||||||
templ.KV("hover:bg-surface1 hover:cursor-pointer transition", hasTeams) }
|
|
||||||
>
|
>
|
||||||
<!-- Series Header -->
|
<!-- Series Header -->
|
||||||
<div class="bg-mantle px-3 py-1.5 flex items-center justify-between border-b border-surface1">
|
<div class="bg-mantle px-3 py-1.5 flex items-center justify-between border-b border-surface1">
|
||||||
|
|||||||
@@ -5,43 +5,20 @@ import "git.haelnorr.com/h/oslstats/internal/contexts"
|
|||||||
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
templ SeasonLeagueFinalsPage(
|
templ SeasonLeagueFinalsPage(season *db.Season, league *db.League, bracket *db.PlayoffBracket) {
|
||||||
season *db.Season,
|
|
||||||
league *db.League,
|
|
||||||
bracket *db.PlayoffBracket,
|
|
||||||
topGoals []*db.LeagueTopGoalScorer,
|
|
||||||
topAssists []*db.LeagueTopAssister,
|
|
||||||
topSaves []*db.LeagueTopSaver,
|
|
||||||
allStats []*db.LeaguePlayerStats,
|
|
||||||
) {
|
|
||||||
@SeasonLeagueLayout("finals", season, league) {
|
@SeasonLeagueLayout("finals", season, league) {
|
||||||
@SeasonLeagueFinals(season, league, bracket, topGoals, topAssists, topSaves, allStats)
|
@SeasonLeagueFinals(season, league, bracket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SeasonLeagueFinals(
|
templ SeasonLeagueFinals(season *db.Season, league *db.League, bracket *db.PlayoffBracket) {
|
||||||
season *db.Season,
|
|
||||||
league *db.League,
|
|
||||||
bracket *db.PlayoffBracket,
|
|
||||||
topGoals []*db.LeagueTopGoalScorer,
|
|
||||||
topAssists []*db.LeagueTopAssister,
|
|
||||||
topSaves []*db.LeagueTopSaver,
|
|
||||||
allStats []*db.LeaguePlayerStats,
|
|
||||||
) {
|
|
||||||
{{
|
{{
|
||||||
permCache := contexts.Permissions(ctx)
|
permCache := contexts.Permissions(ctx)
|
||||||
canManagePlayoffs := permCache != nil && permCache.HasPermission(permissions.PlayoffsManage)
|
canManagePlayoffs := permCache != nil && permCache.HasPermission(permissions.PlayoffsManage)
|
||||||
hasStats := len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 || len(allStats) > 0
|
|
||||||
}}
|
}}
|
||||||
<div id="finals-content">
|
<div id="finals-content">
|
||||||
if bracket != nil {
|
if bracket != nil {
|
||||||
@PlayoffBracketView(season, league, bracket)
|
@PlayoffBracketView(season, league, bracket)
|
||||||
<!-- Finals Stats Section -->
|
|
||||||
if hasStats {
|
|
||||||
<div class="mt-8">
|
|
||||||
@finalsStatsSection(season, league, topGoals, topAssists, topSaves, allStats)
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else if canManagePlayoffs {
|
} else if canManagePlayoffs {
|
||||||
@finalsNotYetConfigured(season, league)
|
@finalsNotYetConfigured(season, league)
|
||||||
} else {
|
} else {
|
||||||
@@ -50,43 +27,6 @@ templ SeasonLeagueFinals(
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ finalsStatsSection(
|
|
||||||
season *db.Season,
|
|
||||||
league *db.League,
|
|
||||||
topGoals []*db.LeagueTopGoalScorer,
|
|
||||||
topAssists []*db.LeagueTopAssister,
|
|
||||||
topSaves []*db.LeagueTopSaver,
|
|
||||||
allStats []*db.LeaguePlayerStats,
|
|
||||||
) {
|
|
||||||
<script src="/static/js/sortable-table.js"></script>
|
|
||||||
<div class="space-y-8">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-yellow">★</span>
|
|
||||||
<h2 class="text-xl font-bold text-text">Finals Stats</h2>
|
|
||||||
</div>
|
|
||||||
<!-- Trophy Leaders -->
|
|
||||||
if len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 {
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h3 class="text-lg font-bold text-text text-center">Trophy Leaders</h3>
|
|
||||||
<div class="flex flex-col items-center gap-6 w-full">
|
|
||||||
<div class="flex flex-col lg:flex-row gap-6 justify-center items-stretch w-full lg:w-auto">
|
|
||||||
@topGoalScorersTable(season, league, topGoals)
|
|
||||||
@topAssistersTable(season, league, topAssists)
|
|
||||||
</div>
|
|
||||||
@topSaversTable(season, league, topSaves)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<!-- All Finals Stats -->
|
|
||||||
if len(allStats) > 0 {
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h3 class="text-lg font-bold text-text text-center">All Finals Stats</h3>
|
|
||||||
@allStatsTable(season, league, allStats)
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ finalsNotYetConfigured(season *db.Season, league *db.League) {
|
templ finalsNotYetConfigured(season *db.Season, league *db.League) {
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
|||||||
@@ -1,660 +0,0 @@
|
|||||||
package seasonsview
|
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
|
||||||
import "fmt"
|
|
||||||
import "sort"
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
// seriesTeamName returns a display name for a team in the series, or "TBD" if nil
|
|
||||||
func seriesTeamName(team *db.Team) string {
|
|
||||||
if team == nil {
|
|
||||||
return "TBD"
|
|
||||||
}
|
|
||||||
return team.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// seriesTeamShortName returns a short name for a team in the series, or "TBD" if nil
|
|
||||||
func seriesTeamShortName(team *db.Team) string {
|
|
||||||
if team == nil {
|
|
||||||
return "TBD"
|
|
||||||
}
|
|
||||||
return team.ShortName
|
|
||||||
}
|
|
||||||
|
|
||||||
// roundDisplayName converts a round slug to a human-readable name
|
|
||||||
func roundDisplayName(round string) string {
|
|
||||||
switch round {
|
|
||||||
case "upper_bracket":
|
|
||||||
return "Upper Bracket"
|
|
||||||
case "lower_bracket":
|
|
||||||
return "Lower Bracket"
|
|
||||||
case "upper_final":
|
|
||||||
return "Upper Final"
|
|
||||||
case "lower_final":
|
|
||||||
return "Lower Final"
|
|
||||||
case "quarter_final":
|
|
||||||
return "Quarter Final"
|
|
||||||
case "semi_final":
|
|
||||||
return "Semi Final"
|
|
||||||
case "elimination_final":
|
|
||||||
return "Elimination Final"
|
|
||||||
case "qualifying_final":
|
|
||||||
return "Qualifying Final"
|
|
||||||
case "preliminary_final":
|
|
||||||
return "Preliminary Final"
|
|
||||||
case "third_place":
|
|
||||||
return "Third Place Playoff"
|
|
||||||
case "grand_final":
|
|
||||||
return "Grand Final"
|
|
||||||
default:
|
|
||||||
return strings.ReplaceAll(round, "_", " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeriesDetailLayout renders the series detail page layout with header and
|
|
||||||
// tab navigation. Tab content is rendered as children.
|
|
||||||
templ SeriesDetailLayout(activeTab string, series *db.PlayoffSeries, schedule *db.PlayoffSeriesSchedule) {
|
|
||||||
{{
|
|
||||||
backURL := fmt.Sprintf("/seasons/%s/leagues/%s/finals",
|
|
||||||
series.Bracket.Season.ShortName, series.Bracket.League.ShortName)
|
|
||||||
isCompleted := series.Status == db.SeriesStatusCompleted
|
|
||||||
team1Name := seriesTeamName(series.Team1)
|
|
||||||
team2Name := seriesTeamName(series.Team2)
|
|
||||||
boLabel := fmt.Sprintf("BO%d", series.MatchesToWin*2-1)
|
|
||||||
}}
|
|
||||||
@baseview.Layout(fmt.Sprintf("%s — %s vs %s", series.Label, team1Name, team2Name)) {
|
|
||||||
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center gap-4 mb-2">
|
|
||||||
<h1 class="text-3xl font-bold text-text">
|
|
||||||
{ team1Name }
|
|
||||||
<span class="text-subtext0 font-normal">vs</span>
|
|
||||||
{ team2Name }
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
|
||||||
{ series.Label }
|
|
||||||
</span>
|
|
||||||
<span class="px-2 py-0.5 bg-surface1 text-subtext0 rounded text-xs font-mono">
|
|
||||||
{ boLabel }
|
|
||||||
</span>
|
|
||||||
if series.Team1Seed != nil || series.Team2Seed != nil {
|
|
||||||
<span class="px-2 py-0.5 bg-surface1 text-subtext0 rounded text-xs font-mono">
|
|
||||||
if series.Team1Seed != nil && series.Team2Seed != nil {
|
|
||||||
Seed { fmt.Sprint(*series.Team1Seed) } vs { fmt.Sprint(*series.Team2Seed) }
|
|
||||||
} else if series.Team1Seed != nil {
|
|
||||||
Seed { fmt.Sprint(*series.Team1Seed) }
|
|
||||||
} else if series.Team2Seed != nil {
|
|
||||||
Seed { fmt.Sprint(*series.Team2Seed) }
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<span class="text-subtext1 text-sm">
|
|
||||||
{ series.Bracket.Season.Name } — { series.Bracket.League.Name }
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={ templ.SafeURL(backURL) }
|
|
||||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
|
||||||
bg-surface1 hover:bg-surface2 text-text transition"
|
|
||||||
>
|
|
||||||
Back to Bracket
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Tab Navigation -->
|
|
||||||
<nav class="bg-surface0 border-b border-surface1" data-tab-nav="series-detail-content">
|
|
||||||
<ul class="flex flex-wrap">
|
|
||||||
@seriesTabItem("overview", "Overview", activeTab, series)
|
|
||||||
if isCompleted {
|
|
||||||
@seriesTabItem("analysis", "Match Analysis", activeTab, series)
|
|
||||||
} else {
|
|
||||||
@seriesTabItem("preview", "Match Preview", activeTab, series)
|
|
||||||
@seriesTabItem("scheduling", "Schedule", activeTab, series)
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<!-- Content Area -->
|
|
||||||
<main id="series-detail-content">
|
|
||||||
{ children... }
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<script src="/static/js/tabs.js" defer></script>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesTabItem(section string, label string, activeTab string, series *db.PlayoffSeries) {
|
|
||||||
{{
|
|
||||||
isActive := section == activeTab
|
|
||||||
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
|
|
||||||
activeClasses := "border-blue text-blue font-semibold"
|
|
||||||
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
|
||||||
url := fmt.Sprintf("/series/%d/%s", series.ID, section)
|
|
||||||
}}
|
|
||||||
<li class="inline-block">
|
|
||||||
<a
|
|
||||||
href={ templ.SafeURL(url) }
|
|
||||||
hx-post={ url }
|
|
||||||
hx-target="#series-detail-content"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
hx-push-url={ url }
|
|
||||||
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
|
||||||
>
|
|
||||||
{ label }
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Full page wrappers (for GET requests / direct navigation) ====================
|
|
||||||
|
|
||||||
templ SeriesDetailOverviewPage(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
currentSchedule *db.PlayoffSeriesSchedule,
|
|
||||||
canSchedule bool,
|
|
||||||
userTeamID int,
|
|
||||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
|
||||||
) {
|
|
||||||
@SeriesDetailLayout("overview", series, currentSchedule) {
|
|
||||||
@SeriesDetailOverviewContent(series, currentSchedule, canSchedule, userTeamID, rosters)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ SeriesDetailPreviewPage(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
currentSchedule *db.PlayoffSeriesSchedule,
|
|
||||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
|
||||||
previewData *db.MatchPreviewData,
|
|
||||||
) {
|
|
||||||
@SeriesDetailLayout("preview", series, currentSchedule) {
|
|
||||||
@SeriesDetailPreviewContent(series, rosters, previewData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ SeriesDetailAnalysisPage(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
currentSchedule *db.PlayoffSeriesSchedule,
|
|
||||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
|
||||||
previewData *db.MatchPreviewData,
|
|
||||||
) {
|
|
||||||
@SeriesDetailLayout("analysis", series, currentSchedule) {
|
|
||||||
@SeriesDetailAnalysisContent(series, rosters, previewData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ SeriesDetailSchedulePage(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
currentSchedule *db.PlayoffSeriesSchedule,
|
|
||||||
history []*db.PlayoffSeriesSchedule,
|
|
||||||
canSchedule bool,
|
|
||||||
userTeamID int,
|
|
||||||
) {
|
|
||||||
@SeriesDetailLayout("scheduling", series, currentSchedule) {
|
|
||||||
@SeriesDetailScheduleContent(series, currentSchedule, history, canSchedule, userTeamID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Tab content components (for POST requests / HTMX swaps) ====================
|
|
||||||
|
|
||||||
templ SeriesDetailOverviewContent(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
currentSchedule *db.PlayoffSeriesSchedule,
|
|
||||||
canSchedule bool,
|
|
||||||
userTeamID int,
|
|
||||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
|
||||||
) {
|
|
||||||
{{
|
|
||||||
permCache := contexts.Permissions(ctx)
|
|
||||||
canManage := permCache.HasPermission(permissions.PlayoffsManage)
|
|
||||||
}}
|
|
||||||
@seriesOverviewTab(series, currentSchedule, rosters, canSchedule, canManage, userTeamID)
|
|
||||||
}
|
|
||||||
|
|
||||||
templ SeriesDetailPreviewContent(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
|
||||||
previewData *db.MatchPreviewData,
|
|
||||||
) {
|
|
||||||
@seriesMatchPreviewTab(series, rosters, previewData)
|
|
||||||
}
|
|
||||||
|
|
||||||
templ SeriesDetailAnalysisContent(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
|
||||||
previewData *db.MatchPreviewData,
|
|
||||||
) {
|
|
||||||
@seriesMatchAnalysisTab(series, rosters, previewData)
|
|
||||||
}
|
|
||||||
|
|
||||||
templ SeriesDetailScheduleContent(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
currentSchedule *db.PlayoffSeriesSchedule,
|
|
||||||
history []*db.PlayoffSeriesSchedule,
|
|
||||||
canSchedule bool,
|
|
||||||
userTeamID int,
|
|
||||||
) {
|
|
||||||
{{
|
|
||||||
permCache := contexts.Permissions(ctx)
|
|
||||||
canManage := permCache.HasPermission(permissions.PlayoffsManage)
|
|
||||||
}}
|
|
||||||
@seriesScheduleTab(series, currentSchedule, history, canSchedule, canManage, userTeamID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Overview Tab ====================
|
|
||||||
templ seriesOverviewTab(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
currentSchedule *db.PlayoffSeriesSchedule,
|
|
||||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
|
||||||
canSchedule bool,
|
|
||||||
canManage bool,
|
|
||||||
userTeamID int,
|
|
||||||
) {
|
|
||||||
{{
|
|
||||||
isCompleted := series.Status == db.SeriesStatusCompleted
|
|
||||||
isBye := series.Status == db.SeriesStatusBye
|
|
||||||
bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil
|
|
||||||
showUploadPrompt := canManage && !isCompleted && !isBye && bothTeamsAssigned
|
|
||||||
}}
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Series Score + Schedule Row -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
@seriesScoreDisplay(series)
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
@seriesScheduleSummary(series, currentSchedule)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload Prompt (for admins when series is in progress) -->
|
|
||||||
if showUploadPrompt {
|
|
||||||
@seriesUploadPrompt(series)
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Match List -->
|
|
||||||
if len(series.Matches) > 0 {
|
|
||||||
@seriesMatchList(series)
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Series Context -->
|
|
||||||
@seriesContextCard(series)
|
|
||||||
|
|
||||||
<!-- Team Rosters -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
if series.Team1 != nil {
|
|
||||||
@seriesTeamSection(series.Team1, rosters["team1"], series.Bracket.Season, series.Bracket.League)
|
|
||||||
}
|
|
||||||
if series.Team2 != nil {
|
|
||||||
@seriesTeamSection(series.Team2, rosters["team2"], series.Bracket.Season, series.Bracket.League)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesUploadPrompt(series *db.PlayoffSeries) {
|
|
||||||
{{
|
|
||||||
// Check if there are pending results waiting for review
|
|
||||||
hasPendingMatches := false
|
|
||||||
for _, match := range series.Matches {
|
|
||||||
if match.FixtureID != nil && match.Status == "pending" {
|
|
||||||
hasPendingMatches = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
<div 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>
|
|
||||||
} 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>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesScoreDisplay(series *db.PlayoffSeries) {
|
|
||||||
{{
|
|
||||||
isCompleted := series.Status == db.SeriesStatusCompleted
|
|
||||||
team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID
|
|
||||||
team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID
|
|
||||||
isBye := series.Status == db.SeriesStatusBye
|
|
||||||
}}
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-bold text-text">Series Score</h2>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
@seriesStatusBadge(series.Status)
|
|
||||||
@seriesFormatBadge(series.MatchesToWin)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
if isBye {
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<p class="text-lg text-subtext0">Bye — team advances automatically</p>
|
|
||||||
</div>
|
|
||||||
} else if series.Team1 == nil && series.Team2 == nil {
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<p class="text-lg text-subtext0">Teams not yet determined</p>
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class="flex items-center justify-center gap-6 py-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
if team1Won {
|
|
||||||
<span class="text-2xl">🏆</span>
|
|
||||||
}
|
|
||||||
if series.Team1 != nil && series.Team1.Color != "" {
|
|
||||||
<span
|
|
||||||
class="px-2.5 py-1 rounded text-sm font-bold"
|
|
||||||
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
|
||||||
series.Team1.Color, series.Team1.Color, series.Team1.Color) }
|
|
||||||
>
|
|
||||||
{ seriesTeamShortName(series.Team1) }
|
|
||||||
</span>
|
|
||||||
} else {
|
|
||||||
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
|
||||||
{ seriesTeamShortName(series.Team1) }
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<span class="text-4xl font-bold text-text">{ fmt.Sprint(series.Team1Wins) }</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<span class="text-4xl text-subtext0 font-light leading-none">–</span>
|
|
||||||
if isCompleted {
|
|
||||||
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs font-semibold mt-1">
|
|
||||||
FINAL
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-4xl font-bold text-text">{ fmt.Sprint(series.Team2Wins) }</span>
|
|
||||||
if series.Team2 != nil && series.Team2.Color != "" {
|
|
||||||
<span
|
|
||||||
class="px-2.5 py-1 rounded text-sm font-bold"
|
|
||||||
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
|
||||||
series.Team2.Color, series.Team2.Color, series.Team2.Color) }
|
|
||||||
>
|
|
||||||
{ seriesTeamShortName(series.Team2) }
|
|
||||||
</span>
|
|
||||||
} else {
|
|
||||||
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
|
||||||
{ seriesTeamShortName(series.Team2) }
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
if team2Won {
|
|
||||||
<span class="text-2xl">🏆</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesScheduleSummary(series *db.PlayoffSeries, schedule *db.PlayoffSeriesSchedule) {
|
|
||||||
{{
|
|
||||||
isCompleted := series.Status == db.SeriesStatusCompleted
|
|
||||||
}}
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden h-full">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">Schedule</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 flex flex-col justify-center h-[calc(100%-3rem)]">
|
|
||||||
if schedule == nil {
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-subtext1 text-sm">No time scheduled</p>
|
|
||||||
</div>
|
|
||||||
} else if schedule.Status == db.ScheduleStatusAccepted && schedule.ScheduledTime != nil {
|
|
||||||
<div class="text-center space-y-2">
|
|
||||||
if isCompleted {
|
|
||||||
<span class="px-2 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium">
|
|
||||||
Played
|
|
||||||
</span>
|
|
||||||
} else {
|
|
||||||
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
|
||||||
Confirmed
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<p class="text-text font-medium">
|
|
||||||
@localtime(schedule.ScheduledTime, "date")
|
|
||||||
</p>
|
|
||||||
<p class="text-text text-lg font-bold">
|
|
||||||
@localtime(schedule.ScheduledTime, "time")
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
} else if schedule.Status == db.ScheduleStatusPending && schedule.ScheduledTime != nil {
|
|
||||||
<div class="text-center space-y-2">
|
|
||||||
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
|
||||||
Proposed
|
|
||||||
</span>
|
|
||||||
<p class="text-text font-medium">
|
|
||||||
@localtime(schedule.ScheduledTime, "date")
|
|
||||||
</p>
|
|
||||||
<p class="text-text text-lg font-bold">
|
|
||||||
@localtime(schedule.ScheduledTime, "time")
|
|
||||||
</p>
|
|
||||||
<p class="text-subtext1 text-xs">Awaiting confirmation</p>
|
|
||||||
</div>
|
|
||||||
} else if schedule.Status == db.ScheduleStatusCancelled {
|
|
||||||
<div class="text-center space-y-2">
|
|
||||||
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
|
|
||||||
Cancelled
|
|
||||||
</span>
|
|
||||||
if schedule.RescheduleReason != nil {
|
|
||||||
<p class="text-subtext1 text-xs">{ *schedule.RescheduleReason }</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-subtext1 text-sm">No time confirmed</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesMatchList(series *db.PlayoffSeries) {
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">Matches</h2>
|
|
||||||
</div>
|
|
||||||
<div class="divide-y divide-surface1">
|
|
||||||
for _, match := range series.Matches {
|
|
||||||
@seriesMatchRow(series, match)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesMatchRow(series *db.PlayoffSeries, match *db.PlayoffMatch) {
|
|
||||||
{{
|
|
||||||
matchLabel := fmt.Sprintf("Game %d", match.MatchNumber)
|
|
||||||
isPending := match.Status == "pending"
|
|
||||||
isCompleted := match.Status == "completed"
|
|
||||||
hasFixture := match.FixtureID != nil
|
|
||||||
_ = hasFixture
|
|
||||||
}}
|
|
||||||
<div class="flex items-center justify-between px-4 py-3 hover:bg-surface0 transition-colors">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-sm font-medium text-text">{ matchLabel }</span>
|
|
||||||
if isPending {
|
|
||||||
<span class="px-1.5 py-0.5 bg-surface1 text-subtext0 rounded text-xs">
|
|
||||||
Pending
|
|
||||||
</span>
|
|
||||||
} else if isCompleted {
|
|
||||||
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs">
|
|
||||||
Complete
|
|
||||||
</span>
|
|
||||||
} else {
|
|
||||||
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs">
|
|
||||||
{ match.Status }
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
if match.FixtureID != nil {
|
|
||||||
<a
|
|
||||||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/overview", *match.FixtureID)) }
|
|
||||||
class="px-3 py-1 bg-surface1 hover:bg-surface2 text-text rounded text-xs
|
|
||||||
font-medium transition hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
View Details
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesContextCard(series *db.PlayoffSeries) {
|
|
||||||
{{
|
|
||||||
// Determine advancement info
|
|
||||||
winnerAdvances := ""
|
|
||||||
loserAdvances := ""
|
|
||||||
|
|
||||||
if series.WinnerNextID != nil {
|
|
||||||
// Look through bracket series for the target
|
|
||||||
if series.Bracket != nil {
|
|
||||||
for _, s := range series.Bracket.Series {
|
|
||||||
if s.ID == *series.WinnerNextID {
|
|
||||||
winnerAdvances = s.Label
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if winnerAdvances == "" {
|
|
||||||
winnerAdvances = "next round"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if series.LoserNextID != nil {
|
|
||||||
if series.Bracket != nil {
|
|
||||||
for _, s := range series.Bracket.Series {
|
|
||||||
if s.ID == *series.LoserNextID {
|
|
||||||
loserAdvances = s.Label
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if loserAdvances == "" {
|
|
||||||
loserAdvances = "next round"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">Series Info</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 space-y-3">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-sm text-subtext0 w-24 shrink-0">Round</span>
|
|
||||||
<span class="text-sm font-medium text-text">{ roundDisplayName(series.Round) }</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-sm text-subtext0 w-24 shrink-0">Format</span>
|
|
||||||
<span class="text-sm font-medium text-text">Best of { fmt.Sprint(series.MatchesToWin*2 - 1) } (first to { fmt.Sprint(series.MatchesToWin) })</span>
|
|
||||||
</div>
|
|
||||||
if series.Team1Seed != nil && series.Team2Seed != nil {
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-sm text-subtext0 w-24 shrink-0">Seeding</span>
|
|
||||||
<span class="text-sm font-medium text-text">
|
|
||||||
{ ordinal(*series.Team1Seed) } seed vs { ordinal(*series.Team2Seed) } seed
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
if winnerAdvances != "" {
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-sm text-subtext0 w-24 shrink-0">Winner →</span>
|
|
||||||
<span class="text-sm font-medium text-green">{ winnerAdvances }</span>
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-sm text-subtext0 w-24 shrink-0">Winner →</span>
|
|
||||||
<span class="text-sm font-medium text-yellow">Champion</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
if loserAdvances != "" {
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-sm text-subtext0 w-24 shrink-0">Loser →</span>
|
|
||||||
<span class="text-sm font-medium text-peach">{ loserAdvances }</span>
|
|
||||||
</div>
|
|
||||||
} else if series.WinnerNextID != nil {
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-sm text-subtext0 w-24 shrink-0">Loser →</span>
|
|
||||||
<span class="text-sm font-medium text-red">Eliminated</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, season *db.Season, league *db.League) {
|
|
||||||
{{
|
|
||||||
// Sort with managers first
|
|
||||||
sort.SliceStable(players, func(i, j int) bool {
|
|
||||||
return players[i].IsManager && !players[j].IsManager
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
|
||||||
<h3 class="text-md font-bold">
|
|
||||||
@links.TeamNameLinkInSeason(team, season, league)
|
|
||||||
</h3>
|
|
||||||
if team.Color != "" {
|
|
||||||
<span
|
|
||||||
class="w-4 h-4 rounded-full border border-surface1"
|
|
||||||
style={ fmt.Sprintf("background-color: %s", team.Color) }
|
|
||||||
></span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
if len(players) == 0 {
|
|
||||||
<div class="p-4">
|
|
||||||
<p class="text-subtext1 text-sm text-center py-2">No players on roster.</p>
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="space-y-1">
|
|
||||||
for _, p := range players {
|
|
||||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface0 transition">
|
|
||||||
<span class="text-sm">
|
|
||||||
@links.PlayerLink(p.Player)
|
|
||||||
</span>
|
|
||||||
if p.IsManager {
|
|
||||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
|
||||||
★ Manager
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
if p.IsFreeAgent {
|
|
||||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
|
||||||
FREE AGENT
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
package seasonsview
|
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// seriesMatchAnalysisTab renders the full Match Analysis tab for completed series.
|
|
||||||
// Shows final series score, individual match results, aggregated team stats,
|
|
||||||
// top performers, and league context.
|
|
||||||
templ seriesMatchAnalysisTab(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
|
||||||
preview *db.MatchPreviewData,
|
|
||||||
) {
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Final Series Score -->
|
|
||||||
@seriesAnalysisScoreHeader(series)
|
|
||||||
|
|
||||||
<!-- Individual Match Results -->
|
|
||||||
if len(series.Matches) > 0 {
|
|
||||||
@seriesAnalysisMatchResults(series)
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- League Context (from preview data) -->
|
|
||||||
if preview != nil {
|
|
||||||
@seriesAnalysisLeagueContext(series, preview)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// seriesAnalysisScoreHeader renders the final series score in a prominent display.
|
|
||||||
templ seriesAnalysisScoreHeader(series *db.PlayoffSeries) {
|
|
||||||
{{
|
|
||||||
team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID
|
|
||||||
team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID
|
|
||||||
}}
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">Final Series Score</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex items-center justify-center gap-6 sm:gap-10">
|
|
||||||
<!-- Team 1 -->
|
|
||||||
<div class="flex flex-col items-center text-center flex-1">
|
|
||||||
if series.Team1 != nil && series.Team1.Color != "" {
|
|
||||||
<div
|
|
||||||
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
|
||||||
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
|
|
||||||
if series.Team1 != nil {
|
|
||||||
@links.TeamNameLinkInSeason(series.Team1, series.Bracket.Season, series.Bracket.League)
|
|
||||||
} else {
|
|
||||||
TBD
|
|
||||||
}
|
|
||||||
</h3>
|
|
||||||
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", team1Won), templ.KV("text-text", !team1Won) }>
|
|
||||||
{ fmt.Sprint(series.Team1Wins) }
|
|
||||||
</span>
|
|
||||||
if team1Won {
|
|
||||||
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<!-- Divider -->
|
|
||||||
<div class="flex flex-col items-center shrink-0">
|
|
||||||
<span class="text-4xl text-subtext0 font-light">–</span>
|
|
||||||
</div>
|
|
||||||
<!-- Team 2 -->
|
|
||||||
<div class="flex flex-col items-center text-center flex-1">
|
|
||||||
if series.Team2 != nil && series.Team2.Color != "" {
|
|
||||||
<div
|
|
||||||
class="w-12 h-12 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
|
||||||
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
<h3 class="text-lg sm:text-xl font-bold text-text mb-1">
|
|
||||||
if series.Team2 != nil {
|
|
||||||
@links.TeamNameLinkInSeason(series.Team2, series.Bracket.Season, series.Bracket.League)
|
|
||||||
} else {
|
|
||||||
TBD
|
|
||||||
}
|
|
||||||
</h3>
|
|
||||||
<span class={ "text-5xl sm:text-6xl font-bold", templ.KV("text-green", team2Won), templ.KV("text-text", !team2Won) }>
|
|
||||||
{ fmt.Sprint(series.Team2Wins) }
|
|
||||||
</span>
|
|
||||||
if team2Won {
|
|
||||||
<span class="mt-2 px-3 py-1 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// seriesAnalysisMatchResults shows individual match results as a compact list.
|
|
||||||
templ seriesAnalysisMatchResults(series *db.PlayoffSeries) {
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">Match Results</h2>
|
|
||||||
</div>
|
|
||||||
<div class="divide-y divide-surface1">
|
|
||||||
for _, match := range series.Matches {
|
|
||||||
@seriesAnalysisMatchRow(series, match)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesAnalysisMatchRow(series *db.PlayoffSeries, match *db.PlayoffMatch) {
|
|
||||||
{{
|
|
||||||
matchLabel := fmt.Sprintf("Game %d", match.MatchNumber)
|
|
||||||
isCompleted := match.Status == "completed"
|
|
||||||
}}
|
|
||||||
<div class="flex items-center justify-between px-6 py-3 hover:bg-surface0 transition-colors">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<span class="text-sm font-medium text-subtext0 w-16">{ matchLabel }</span>
|
|
||||||
if isCompleted {
|
|
||||||
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs">
|
|
||||||
Complete
|
|
||||||
</span>
|
|
||||||
} else {
|
|
||||||
<span class="px-1.5 py-0.5 bg-surface1 text-subtext0 rounded text-xs">
|
|
||||||
{ match.Status }
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
if match.FixtureID != nil {
|
|
||||||
<a
|
|
||||||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/overview", *match.FixtureID)) }
|
|
||||||
class="px-3 py-1 bg-surface1 hover:bg-surface2 text-text rounded text-xs
|
|
||||||
font-medium transition hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
View Details
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// seriesAnalysisLeagueContext shows how the teams sit in the league standings.
|
|
||||||
templ seriesAnalysisLeagueContext(series *db.PlayoffSeries, preview *db.MatchPreviewData) {
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">League Context</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<!-- Team Name Headers -->
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="flex-1 text-right pr-4">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
if series.Team1 != nil && series.Team1.Color != "" {
|
|
||||||
<span
|
|
||||||
class="w-3 h-3 rounded-full shrink-0"
|
|
||||||
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
|
||||||
></span>
|
|
||||||
}
|
|
||||||
<span class="text-sm font-bold text-text">{ seriesTeamShortName(series.Team1) }</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-28 sm:w-36 text-center shrink-0"></div>
|
|
||||||
<div class="flex-1 text-left pl-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-sm font-bold text-text">{ seriesTeamShortName(series.Team2) }</span>
|
|
||||||
if series.Team2 != nil && series.Team2.Color != "" {
|
|
||||||
<span
|
|
||||||
class="w-3 h-3 rounded-full shrink-0"
|
|
||||||
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
|
|
||||||
></span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-0">
|
|
||||||
{{
|
|
||||||
homePos := ordinal(preview.HomePosition)
|
|
||||||
awayPos := ordinal(preview.AwayPosition)
|
|
||||||
if preview.HomePosition == 0 {
|
|
||||||
homePos = "N/A"
|
|
||||||
}
|
|
||||||
if preview.AwayPosition == 0 {
|
|
||||||
awayPos = "N/A"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
@previewStatRow(
|
|
||||||
homePos,
|
|
||||||
"Position",
|
|
||||||
awayPos,
|
|
||||||
preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition,
|
|
||||||
preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition,
|
|
||||||
)
|
|
||||||
@previewStatRow(
|
|
||||||
fmt.Sprint(preview.HomeRecord.Points),
|
|
||||||
"Points",
|
|
||||||
fmt.Sprint(preview.AwayRecord.Points),
|
|
||||||
preview.HomeRecord.Points > preview.AwayRecord.Points,
|
|
||||||
preview.AwayRecord.Points > preview.HomeRecord.Points,
|
|
||||||
)
|
|
||||||
@previewStatRow(
|
|
||||||
fmt.Sprintf("%d-%d-%d-%d",
|
|
||||||
preview.HomeRecord.Wins,
|
|
||||||
preview.HomeRecord.OvertimeWins,
|
|
||||||
preview.HomeRecord.OvertimeLosses,
|
|
||||||
preview.HomeRecord.Losses,
|
|
||||||
),
|
|
||||||
"Record",
|
|
||||||
fmt.Sprintf("%d-%d-%d-%d",
|
|
||||||
preview.AwayRecord.Wins,
|
|
||||||
preview.AwayRecord.OvertimeWins,
|
|
||||||
preview.AwayRecord.OvertimeLosses,
|
|
||||||
preview.AwayRecord.Losses,
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
{{
|
|
||||||
homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst
|
|
||||||
awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst
|
|
||||||
}}
|
|
||||||
@previewStatRow(
|
|
||||||
fmt.Sprintf("%+d", homeDiff),
|
|
||||||
"Goal Diff",
|
|
||||||
fmt.Sprintf("%+d", awayDiff),
|
|
||||||
homeDiff > awayDiff,
|
|
||||||
awayDiff > homeDiff,
|
|
||||||
)
|
|
||||||
<!-- Recent Form -->
|
|
||||||
if len(preview.HomeRecentGames) > 0 || len(preview.AwayRecentGames) > 0 {
|
|
||||||
<div class="flex items-center py-3 border-b border-surface1 last:border-b-0">
|
|
||||||
<div class="flex-1 flex justify-end pr-4">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
for _, g := range preview.HomeRecentGames {
|
|
||||||
@gameOutcomeIcon(g)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-28 sm:w-36 text-center shrink-0">
|
|
||||||
<span class="text-xs font-semibold text-subtext0 uppercase tracking-wider">Form</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 flex pl-4">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
for _, g := range preview.AwayRecentGames {
|
|
||||||
@gameOutcomeIcon(g)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
package seasonsview
|
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
|
||||||
import "fmt"
|
|
||||||
import "sort"
|
|
||||||
|
|
||||||
// seriesMatchPreviewTab renders the full Match Preview tab for upcoming series.
|
|
||||||
// Shows team standings comparison, recent form, and full rosters side-by-side.
|
|
||||||
templ seriesMatchPreviewTab(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
|
||||||
preview *db.MatchPreviewData,
|
|
||||||
) {
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Team Comparison Header -->
|
|
||||||
if preview != nil {
|
|
||||||
@seriesPreviewHeader(series, preview)
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Form Guide (Last 5 Games) -->
|
|
||||||
if preview != nil {
|
|
||||||
@seriesPreviewFormGuide(series, preview)
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Team Rosters -->
|
|
||||||
@seriesPreviewRosters(series, rosters)
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// seriesPreviewHeader renders the broadcast-style team comparison with standings.
|
|
||||||
templ seriesPreviewHeader(series *db.PlayoffSeries, preview *db.MatchPreviewData) {
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">Team Comparison</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<!-- Team Names and VS -->
|
|
||||||
<div class="flex items-center justify-center gap-4 sm:gap-8 mb-8">
|
|
||||||
<!-- Team 1 -->
|
|
||||||
<div class="flex flex-col items-center text-center flex-1">
|
|
||||||
if series.Team1 != nil && series.Team1.Color != "" {
|
|
||||||
<div
|
|
||||||
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
|
|
||||||
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
<h3 class="text-xl sm:text-2xl font-bold text-text">
|
|
||||||
if series.Team1 != nil {
|
|
||||||
@links.TeamNameLinkInSeason(series.Team1, series.Bracket.Season, series.Bracket.League)
|
|
||||||
} else {
|
|
||||||
TBD
|
|
||||||
}
|
|
||||||
</h3>
|
|
||||||
if series.Team1 != nil {
|
|
||||||
<span class="text-subtext0 text-sm font-mono mt-1">{ series.Team1.ShortName }</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<!-- VS Divider -->
|
|
||||||
<div class="flex flex-col items-center shrink-0">
|
|
||||||
<span class="text-3xl sm:text-4xl font-bold text-subtext0">VS</span>
|
|
||||||
</div>
|
|
||||||
<!-- Team 2 -->
|
|
||||||
<div class="flex flex-col items-center text-center flex-1">
|
|
||||||
if series.Team2 != nil && series.Team2.Color != "" {
|
|
||||||
<div
|
|
||||||
class="w-14 h-14 rounded-full border-2 border-surface1 mb-3 shrink-0"
|
|
||||||
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
<h3 class="text-xl sm:text-2xl font-bold text-text">
|
|
||||||
if series.Team2 != nil {
|
|
||||||
@links.TeamNameLinkInSeason(series.Team2, series.Bracket.Season, series.Bracket.League)
|
|
||||||
} else {
|
|
||||||
TBD
|
|
||||||
}
|
|
||||||
</h3>
|
|
||||||
if series.Team2 != nil {
|
|
||||||
<span class="text-subtext0 text-sm font-mono mt-1">{ series.Team2.ShortName }</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Stats Comparison Grid -->
|
|
||||||
{{
|
|
||||||
homePos := ordinal(preview.HomePosition)
|
|
||||||
awayPos := ordinal(preview.AwayPosition)
|
|
||||||
if preview.HomePosition == 0 {
|
|
||||||
homePos = "N/A"
|
|
||||||
}
|
|
||||||
if preview.AwayPosition == 0 {
|
|
||||||
awayPos = "N/A"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
<div class="space-y-0">
|
|
||||||
@previewStatRow(
|
|
||||||
homePos,
|
|
||||||
"Position",
|
|
||||||
awayPos,
|
|
||||||
preview.HomePosition > 0 && preview.HomePosition < preview.AwayPosition,
|
|
||||||
preview.AwayPosition > 0 && preview.AwayPosition < preview.HomePosition,
|
|
||||||
)
|
|
||||||
@previewStatRow(
|
|
||||||
fmt.Sprint(preview.HomeRecord.Points),
|
|
||||||
"Points",
|
|
||||||
fmt.Sprint(preview.AwayRecord.Points),
|
|
||||||
preview.HomeRecord.Points > preview.AwayRecord.Points,
|
|
||||||
preview.AwayRecord.Points > preview.HomeRecord.Points,
|
|
||||||
)
|
|
||||||
@previewStatRow(
|
|
||||||
fmt.Sprint(preview.HomeRecord.Played),
|
|
||||||
"Played",
|
|
||||||
fmt.Sprint(preview.AwayRecord.Played),
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
@previewStatRow(
|
|
||||||
fmt.Sprint(preview.HomeRecord.Wins),
|
|
||||||
"Wins",
|
|
||||||
fmt.Sprint(preview.AwayRecord.Wins),
|
|
||||||
preview.HomeRecord.Wins > preview.AwayRecord.Wins,
|
|
||||||
preview.AwayRecord.Wins > preview.HomeRecord.Wins,
|
|
||||||
)
|
|
||||||
@previewStatRow(
|
|
||||||
fmt.Sprint(preview.HomeRecord.OvertimeWins),
|
|
||||||
"OT Wins",
|
|
||||||
fmt.Sprint(preview.AwayRecord.OvertimeWins),
|
|
||||||
preview.HomeRecord.OvertimeWins > preview.AwayRecord.OvertimeWins,
|
|
||||||
preview.AwayRecord.OvertimeWins > preview.HomeRecord.OvertimeWins,
|
|
||||||
)
|
|
||||||
@previewStatRow(
|
|
||||||
fmt.Sprint(preview.HomeRecord.OvertimeLosses),
|
|
||||||
"OT Losses",
|
|
||||||
fmt.Sprint(preview.AwayRecord.OvertimeLosses),
|
|
||||||
preview.HomeRecord.OvertimeLosses < preview.AwayRecord.OvertimeLosses,
|
|
||||||
preview.AwayRecord.OvertimeLosses < preview.HomeRecord.OvertimeLosses,
|
|
||||||
)
|
|
||||||
@previewStatRow(
|
|
||||||
fmt.Sprint(preview.HomeRecord.Losses),
|
|
||||||
"Losses",
|
|
||||||
fmt.Sprint(preview.AwayRecord.Losses),
|
|
||||||
preview.HomeRecord.Losses < preview.AwayRecord.Losses,
|
|
||||||
preview.AwayRecord.Losses < preview.HomeRecord.Losses,
|
|
||||||
)
|
|
||||||
@previewStatRow(
|
|
||||||
fmt.Sprint(preview.HomeRecord.GoalsFor),
|
|
||||||
"Goals For",
|
|
||||||
fmt.Sprint(preview.AwayRecord.GoalsFor),
|
|
||||||
preview.HomeRecord.GoalsFor > preview.AwayRecord.GoalsFor,
|
|
||||||
preview.AwayRecord.GoalsFor > preview.HomeRecord.GoalsFor,
|
|
||||||
)
|
|
||||||
@previewStatRow(
|
|
||||||
fmt.Sprint(preview.HomeRecord.GoalsAgainst),
|
|
||||||
"Goals Against",
|
|
||||||
fmt.Sprint(preview.AwayRecord.GoalsAgainst),
|
|
||||||
preview.HomeRecord.GoalsAgainst < preview.AwayRecord.GoalsAgainst,
|
|
||||||
preview.AwayRecord.GoalsAgainst < preview.HomeRecord.GoalsAgainst,
|
|
||||||
)
|
|
||||||
{{
|
|
||||||
homeDiff := preview.HomeRecord.GoalsFor - preview.HomeRecord.GoalsAgainst
|
|
||||||
awayDiff := preview.AwayRecord.GoalsFor - preview.AwayRecord.GoalsAgainst
|
|
||||||
}}
|
|
||||||
@previewStatRow(
|
|
||||||
fmt.Sprintf("%+d", homeDiff),
|
|
||||||
"Goal Diff",
|
|
||||||
fmt.Sprintf("%+d", awayDiff),
|
|
||||||
homeDiff > awayDiff,
|
|
||||||
awayDiff > homeDiff,
|
|
||||||
)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// seriesPreviewFormGuide renders recent form for each team.
|
|
||||||
templ seriesPreviewFormGuide(series *db.PlayoffSeries, preview *db.MatchPreviewData) {
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">Recent Form</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
||||||
<!-- Team 1 Form -->
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center gap-2 mb-4">
|
|
||||||
if series.Team1 != nil && series.Team1.Color != "" {
|
|
||||||
<span
|
|
||||||
class="w-3 h-3 rounded-full shrink-0"
|
|
||||||
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
|
||||||
></span>
|
|
||||||
}
|
|
||||||
<h3 class="text-md font-bold text-text">{ seriesTeamName(series.Team1) }</h3>
|
|
||||||
</div>
|
|
||||||
if len(preview.HomeRecentGames) == 0 {
|
|
||||||
<p class="text-subtext1 text-sm">No recent matches played</p>
|
|
||||||
} else {
|
|
||||||
<div class="flex items-center gap-1.5 mb-4">
|
|
||||||
for _, g := range preview.HomeRecentGames {
|
|
||||||
@gameOutcomeIcon(g)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
for i := len(preview.HomeRecentGames) - 1; i >= 0; i-- {
|
|
||||||
@recentGameRow(preview.HomeRecentGames[i])
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<!-- Team 2 Form -->
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center gap-2 mb-4">
|
|
||||||
if series.Team2 != nil && series.Team2.Color != "" {
|
|
||||||
<span
|
|
||||||
class="w-3 h-3 rounded-full shrink-0"
|
|
||||||
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
|
|
||||||
></span>
|
|
||||||
}
|
|
||||||
<h3 class="text-md font-bold text-text">{ seriesTeamName(series.Team2) }</h3>
|
|
||||||
</div>
|
|
||||||
if len(preview.AwayRecentGames) == 0 {
|
|
||||||
<p class="text-subtext1 text-sm">No recent matches played</p>
|
|
||||||
} else {
|
|
||||||
<div class="flex items-center gap-1.5 mb-4">
|
|
||||||
for _, g := range preview.AwayRecentGames {
|
|
||||||
@gameOutcomeIcon(g)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
for i := len(preview.AwayRecentGames) - 1; i >= 0; i-- {
|
|
||||||
@recentGameRow(preview.AwayRecentGames[i])
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// seriesPreviewRosters renders team rosters side-by-side.
|
|
||||||
templ seriesPreviewRosters(series *db.PlayoffSeries, rosters map[string][]*db.PlayerWithPlayStatus) {
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-6 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">Team Rosters</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
if series.Team1 != nil {
|
|
||||||
@seriesPreviewRosterColumn(series.Team1, rosters["team1"], series.Bracket.Season, series.Bracket.League)
|
|
||||||
}
|
|
||||||
if series.Team2 != nil {
|
|
||||||
@seriesPreviewRosterColumn(series.Team2, rosters["team2"], series.Bracket.Season, series.Bracket.League)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesPreviewRosterColumn(
|
|
||||||
team *db.Team,
|
|
||||||
players []*db.PlayerWithPlayStatus,
|
|
||||||
season *db.Season,
|
|
||||||
league *db.League,
|
|
||||||
) {
|
|
||||||
{{
|
|
||||||
var managers []*db.PlayerWithPlayStatus
|
|
||||||
var roster []*db.PlayerWithPlayStatus
|
|
||||||
for _, p := range players {
|
|
||||||
if p.IsManager {
|
|
||||||
managers = append(managers, p)
|
|
||||||
} else {
|
|
||||||
roster = append(roster, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(roster, func(i, j int) bool {
|
|
||||||
return roster[i].Player.DisplayName() < roster[j].Player.DisplayName()
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
if team.Color != "" {
|
|
||||||
<span
|
|
||||||
class="w-3.5 h-3.5 rounded-full shrink-0 border border-surface1"
|
|
||||||
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
|
||||||
></span>
|
|
||||||
}
|
|
||||||
<h3 class="text-md font-bold">
|
|
||||||
@links.TeamNameLinkInSeason(team, season, league)
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-subtext0">
|
|
||||||
{ fmt.Sprint(len(players)) } players
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
if len(players) == 0 {
|
|
||||||
<p class="text-subtext1 text-sm text-center py-4">No players on roster.</p>
|
|
||||||
} else {
|
|
||||||
<div class="space-y-1">
|
|
||||||
for _, p := range managers {
|
|
||||||
<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface0 border border-surface1">
|
|
||||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium shrink-0">
|
|
||||||
★
|
|
||||||
</span>
|
|
||||||
<span class="text-sm font-medium">
|
|
||||||
@links.PlayerLink(p.Player)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
for _, p := range roster {
|
|
||||||
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface0 transition">
|
|
||||||
<span class="text-sm">
|
|
||||||
@links.PlayerLink(p.Player)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
package seasonsview
|
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// SeriesGameResult holds the parsed result for a single game in the series review
|
|
||||||
type SeriesGameResult struct {
|
|
||||||
GameNumber int
|
|
||||||
Result *db.FixtureResult
|
|
||||||
UnmappedPlayers []string
|
|
||||||
FreeAgentWarnings []FreeAgentWarning
|
|
||||||
}
|
|
||||||
|
|
||||||
templ SeriesReviewResultPage(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
gameResults []*SeriesGameResult,
|
|
||||||
) {
|
|
||||||
{{
|
|
||||||
backURL := fmt.Sprintf("/series/%d", series.ID)
|
|
||||||
team1Name := seriesTeamName(series.Team1)
|
|
||||||
team2Name := seriesTeamName(series.Team2)
|
|
||||||
|
|
||||||
// Calculate series score from the results
|
|
||||||
team1Wins := 0
|
|
||||||
team2Wins := 0
|
|
||||||
for _, gr := range gameResults {
|
|
||||||
if gr.Result != nil {
|
|
||||||
if gr.Result.Winner == "home" {
|
|
||||||
team1Wins++
|
|
||||||
} else {
|
|
||||||
team2Wins++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
@baseview.Layout(fmt.Sprintf("Review Series Result — %s vs %s", team1Name, team2Name)) {
|
|
||||||
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-6 py-6">
|
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold text-text mb-1">Review Series Result</h1>
|
|
||||||
<p class="text-sm text-subtext1">
|
|
||||||
{ team1Name } vs { team2Name }
|
|
||||||
<span class="text-subtext0 ml-1">{ series.Label }</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={ templ.SafeURL(backURL) }
|
|
||||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
|
||||||
bg-surface1 hover:bg-surface2 text-text transition"
|
|
||||||
>
|
|
||||||
Back to Series
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Series Score Summary -->
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">Series Result</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex items-center justify-center gap-8 py-2">
|
|
||||||
<div class="flex flex-col items-center text-center">
|
|
||||||
if series.Team1 != nil && series.Team1.Color != "" {
|
|
||||||
<div
|
|
||||||
class="w-10 h-10 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
|
||||||
style={ "background-color: " + templ.SafeCSS(series.Team1.Color) }
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
<p class="text-sm font-medium text-subtext0 mb-1">{ team1Name }</p>
|
|
||||||
<p class={ "text-5xl font-bold", templ.KV("text-green", team1Wins > team2Wins), templ.KV("text-text", team1Wins <= team2Wins) }>
|
|
||||||
{ fmt.Sprint(team1Wins) }
|
|
||||||
</p>
|
|
||||||
if team1Wins > team2Wins {
|
|
||||||
<span class="mt-1 px-2 py-0.5 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<span class="text-3xl text-subtext0 font-light">–</span>
|
|
||||||
<div class="flex flex-col items-center text-center">
|
|
||||||
if series.Team2 != nil && series.Team2.Color != "" {
|
|
||||||
<div
|
|
||||||
class="w-10 h-10 rounded-full border-2 border-surface1 mb-2 shrink-0"
|
|
||||||
style={ "background-color: " + templ.SafeCSS(series.Team2.Color) }
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
<p class="text-sm font-medium text-subtext0 mb-1">{ team2Name }</p>
|
|
||||||
<p class={ "text-5xl font-bold", templ.KV("text-green", team2Wins > team1Wins), templ.KV("text-text", team2Wins <= team1Wins) }>
|
|
||||||
{ fmt.Sprint(team2Wins) }
|
|
||||||
</p>
|
|
||||||
if team2Wins > team1Wins {
|
|
||||||
<span class="mt-1 px-2 py-0.5 bg-green/20 text-green rounded-full text-xs font-bold uppercase tracking-wider">Winner</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-center text-sm text-subtext1 mt-3">
|
|
||||||
{ fmt.Sprint(len(gameResults)) } game(s) played
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Per-Game Results -->
|
|
||||||
<div class="space-y-4 mb-6">
|
|
||||||
for _, gr := range gameResults {
|
|
||||||
@seriesReviewGameCard(series, gr)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">Actions</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
hx-post={ fmt.Sprintf("/series/%d/results/finalize", series.ID) }
|
|
||||||
hx-swap="none"
|
|
||||||
class="px-6 py-3 bg-green hover:bg-green/75 text-mantle rounded-lg
|
|
||||||
font-semibold transition hover:cursor-pointer text-lg"
|
|
||||||
>
|
|
||||||
Finalize Series
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Discard All Results', message: 'Are you sure you want to discard all uploaded results? You will need to re-upload the match logs for every game.', action: () => htmx.ajax('POST', '/series/%d/results/discard', { swap: 'none' }) } }))", series.ID)) }
|
|
||||||
class="px-6 py-3 bg-red hover:bg-red/80 text-mantle rounded-lg
|
|
||||||
font-medium transition hover:cursor-pointer text-lg"
|
|
||||||
>
|
|
||||||
Discard & Re-upload
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesReviewGameCard(series *db.PlayoffSeries, gr *SeriesGameResult) {
|
|
||||||
{{
|
|
||||||
team1Name := seriesTeamName(series.Team1)
|
|
||||||
team2Name := seriesTeamName(series.Team2)
|
|
||||||
result := gr.Result
|
|
||||||
homeWon := result.Winner == "home"
|
|
||||||
winnerName := team2Name
|
|
||||||
if homeWon {
|
|
||||||
winnerName = team1Name
|
|
||||||
}
|
|
||||||
hasWarnings := result.TamperingDetected || len(gr.UnmappedPlayers) > 0 || len(gr.FreeAgentWarnings) > 0
|
|
||||||
}}
|
|
||||||
<div
|
|
||||||
class="bg-mantle border border-surface1 rounded-lg overflow-hidden"
|
|
||||||
x-data="{ expanded: true }"
|
|
||||||
>
|
|
||||||
<!-- Game Header (clickable to expand/collapse) -->
|
|
||||||
<div
|
|
||||||
class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between
|
|
||||||
hover:bg-surface1 transition hover:cursor-pointer"
|
|
||||||
@click="expanded = !expanded"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<h3 class="text-md font-bold text-text">Game { fmt.Sprint(gr.GameNumber) }</h3>
|
|
||||||
if hasWarnings {
|
|
||||||
<span class="text-yellow text-sm" title="Has warnings">⚠</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-sm text-subtext0">
|
|
||||||
{ team1Name }
|
|
||||||
<span class="font-bold text-text mx-1">{ fmt.Sprint(result.HomeScore) }</span>
|
|
||||||
-
|
|
||||||
<span class="font-bold text-text mx-1">{ fmt.Sprint(result.AwayScore) }</span>
|
|
||||||
{ team2Name }
|
|
||||||
</span>
|
|
||||||
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
|
||||||
{ winnerName }
|
|
||||||
</span>
|
|
||||||
<!-- Expand/collapse indicator -->
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 text-subtext0 transition-transform"
|
|
||||||
:class="expanded && 'rotate-180'"
|
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Collapsible Content -->
|
|
||||||
<div x-show="expanded" x-collapse>
|
|
||||||
<!-- Warnings -->
|
|
||||||
if hasWarnings {
|
|
||||||
<div class="p-4 space-y-3 border-b border-surface1">
|
|
||||||
if result.TamperingDetected && result.TamperingReason != nil {
|
|
||||||
<div class="bg-red/10 border border-red/30 rounded-lg p-3">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<span class="text-red font-bold text-sm">⚠ Inconsistent Data Detected</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-red/80 text-sm">{ *result.TamperingReason }</p>
|
|
||||||
<p class="text-red/60 text-xs mt-1">
|
|
||||||
This does not block finalization but should be reviewed carefully.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
if len(gr.FreeAgentWarnings) > 0 {
|
|
||||||
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-3">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<span class="text-yellow font-bold text-sm">⚠ Free Agent Issues</span>
|
|
||||||
</div>
|
|
||||||
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
|
|
||||||
for _, fa := range gr.FreeAgentWarnings {
|
|
||||||
<li>
|
|
||||||
<span class="text-yellow font-medium">{ fa.Name }</span>
|
|
||||||
<span class="text-yellow/60"> — { fa.Reason }</span>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
if len(gr.UnmappedPlayers) > 0 {
|
|
||||||
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-3">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<span class="text-yellow font-bold text-sm">⚠ Unmapped Players</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-yellow/80 text-sm mb-1">
|
|
||||||
Could not be matched to registered players.
|
|
||||||
</p>
|
|
||||||
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
|
|
||||||
for _, p := range gr.UnmappedPlayers {
|
|
||||||
<li>{ p }</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<!-- Score Display -->
|
|
||||||
<div class="p-6 border-b border-surface1">
|
|
||||||
<div class="flex items-center justify-center gap-8 py-2">
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-sm text-subtext0 mb-1">{ team1Name }</p>
|
|
||||||
<p class={ "text-4xl font-bold", templ.KV("text-green", homeWon), templ.KV("text-text", !homeWon) }>
|
|
||||||
{ fmt.Sprint(result.HomeScore) }
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl text-subtext0 font-light">—</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-sm text-subtext0 mb-1">{ team2Name }</p>
|
|
||||||
<p class={ "text-4xl font-bold", templ.KV("text-green", !homeWon), templ.KV("text-text", homeWon) }>
|
|
||||||
{ fmt.Sprint(result.AwayScore) }
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-4 mt-2 text-xs text-subtext1">
|
|
||||||
if result.Arena != "" {
|
|
||||||
<span>{ result.Arena }</span>
|
|
||||||
}
|
|
||||||
if result.EndReason != "" {
|
|
||||||
<span>{ result.EndReason }</span>
|
|
||||||
}
|
|
||||||
<span>
|
|
||||||
Winner: { winnerName }
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Player Stats Tables -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-0 lg:divide-x divide-surface1">
|
|
||||||
if series.Team1 != nil {
|
|
||||||
@seriesReviewTeamStats(series.Team1, result, "home", series.Bracket.Season, series.Bracket.League)
|
|
||||||
}
|
|
||||||
if series.Team2 != nil {
|
|
||||||
@seriesReviewTeamStats(series.Team2, result, "away", series.Bracket.Season, series.Bracket.League)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesReviewTeamStats(team *db.Team, result *db.FixtureResult, side string, season *db.Season, league *db.League) {
|
|
||||||
{{
|
|
||||||
type playerStat struct {
|
|
||||||
Username string
|
|
||||||
PlayerID *int
|
|
||||||
Stats *db.FixtureResultPlayerStats
|
|
||||||
}
|
|
||||||
finalStats := []*playerStat{}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, ps := range result.PlayerStats {
|
|
||||||
if ps.Team == side && ps.PeriodNum == 3 {
|
|
||||||
if !seen[ps.PlayerGameUserID] {
|
|
||||||
seen[ps.PlayerGameUserID] = true
|
|
||||||
finalStats = append(finalStats, &playerStat{
|
|
||||||
Username: ps.PlayerUsername,
|
|
||||||
PlayerID: ps.PlayerID,
|
|
||||||
Stats: ps,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
<div>
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-2 flex items-center gap-2">
|
|
||||||
if team.Color != "" {
|
|
||||||
<span
|
|
||||||
class="w-3 h-3 rounded-full shrink-0"
|
|
||||||
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
|
||||||
></span>
|
|
||||||
}
|
|
||||||
<h4 class="text-sm font-bold text-text">
|
|
||||||
if side == "home" {
|
|
||||||
Team 1 —
|
|
||||||
} else {
|
|
||||||
Team 2 —
|
|
||||||
}
|
|
||||||
@links.TeamNameLinkInSeason(team, season, league)
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead class="bg-surface0 border-b border-surface1">
|
|
||||||
<tr>
|
|
||||||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
|
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th>
|
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th>
|
|
||||||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-surface1">
|
|
||||||
for _, ps := range finalStats {
|
|
||||||
<tr class="hover:bg-surface0 transition-colors">
|
|
||||||
<td class="px-3 py-2 text-sm">
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
if ps.PlayerID != nil {
|
|
||||||
@links.PlayerLinkFromStats(*ps.PlayerID, ps.Username)
|
|
||||||
} else {
|
|
||||||
<span class="text-text">{ ps.Username }</span>
|
|
||||||
<span class="text-yellow text-xs" title="Unmapped player">?</span>
|
|
||||||
}
|
|
||||||
if ps.Stats.IsFreeAgent {
|
|
||||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
|
||||||
FA
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ intPtrStr(ps.Stats.PeriodsPlayed) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Goals) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Assists) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Saves) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Shots) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Blocks) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Passes) }</td>
|
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Score) }</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
if len(finalStats) == 0 {
|
|
||||||
<tr>
|
|
||||||
<td colspan="9" class="px-3 py-4 text-center text-sm text-subtext1">
|
|
||||||
No player stats recorded
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,504 +0,0 @@
|
|||||||
package seasonsview
|
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// ==================== Schedule Tab ====================
|
|
||||||
templ seriesScheduleTab(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
currentSchedule *db.PlayoffSeriesSchedule,
|
|
||||||
history []*db.PlayoffSeriesSchedule,
|
|
||||||
canSchedule bool,
|
|
||||||
canManage bool,
|
|
||||||
userTeamID int,
|
|
||||||
) {
|
|
||||||
<div class="space-y-6">
|
|
||||||
@seriesScheduleStatus(series, currentSchedule, canSchedule, canManage, userTeamID)
|
|
||||||
@seriesScheduleActions(series, currentSchedule, canSchedule, canManage, userTeamID)
|
|
||||||
@seriesScheduleHistory(series, history)
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesScheduleStatus(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
current *db.PlayoffSeriesSchedule,
|
|
||||||
canSchedule bool,
|
|
||||||
canManage bool,
|
|
||||||
userTeamID int,
|
|
||||||
) {
|
|
||||||
{{
|
|
||||||
bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil
|
|
||||||
}}
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">Schedule Status</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
if !bothTeamsAssigned {
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<div class="text-4xl mb-3">⏳</div>
|
|
||||||
<p class="text-lg text-text font-medium">Waiting for Teams</p>
|
|
||||||
<p class="text-sm text-subtext1 mt-1">
|
|
||||||
Both teams must be determined before scheduling can begin.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
} else if current == nil {
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<div class="text-4xl mb-3">📅</div>
|
|
||||||
<p class="text-lg text-text font-medium">No time scheduled</p>
|
|
||||||
<p class="text-sm text-subtext1 mt-1">
|
|
||||||
if canSchedule {
|
|
||||||
Use the form to propose a time for this series.
|
|
||||||
} else {
|
|
||||||
A team manager needs to propose a time for this series.
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
} else if current.Status == db.ScheduleStatusPending && current.ScheduledTime != nil {
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<div class="text-4xl mb-3">⏳</div>
|
|
||||||
<p class="text-lg text-text font-medium">
|
|
||||||
Proposed:
|
|
||||||
@localtime(current.ScheduledTime, "datetime")
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-subtext1 mt-1">
|
|
||||||
Proposed by
|
|
||||||
<span class="text-text font-medium">{ current.ProposedBy.Name }</span>
|
|
||||||
— awaiting response from the other team
|
|
||||||
</p>
|
|
||||||
if canSchedule && userTeamID != current.ProposedByTeamID {
|
|
||||||
<div class="flex justify-center gap-3 mt-4">
|
|
||||||
<form
|
|
||||||
hx-post={ fmt.Sprintf("/series/%d/schedule/%d/accept", series.ID, current.ID) }
|
|
||||||
hx-swap="none"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded-lg
|
|
||||||
font-medium transition hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
Accept
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<form
|
|
||||||
hx-post={ fmt.Sprintf("/series/%d/schedule/%d/reject", series.ID, current.ID) }
|
|
||||||
hx-swap="none"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
|
||||||
font-medium transition hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
Reject
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
if canSchedule && userTeamID == current.ProposedByTeamID {
|
|
||||||
<div class="flex justify-center mt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={ templ.JSUnsafeFuncCall("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Withdraw Proposal', message: 'Are you sure you want to withdraw your proposed time?', action: () => htmx.ajax('POST', '/series/" + fmt.Sprint(series.ID) + "/schedule/" + fmt.Sprint(current.ID) + "/withdraw', { swap: 'none' }) } }))") }
|
|
||||||
class="px-4 py-2 bg-surface1 hover:bg-surface2 text-text rounded-lg
|
|
||||||
font-medium transition hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
Withdraw Proposal
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} else if current.Status == db.ScheduleStatusAccepted {
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<div class="text-4xl mb-3">✅</div>
|
|
||||||
<p class="text-lg text-green font-medium">
|
|
||||||
Confirmed:
|
|
||||||
@localtime(current.ScheduledTime, "datetime")
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-subtext1 mt-1">
|
|
||||||
Both teams have agreed on this time.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
} else if current.Status == db.ScheduleStatusRejected {
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<div class="text-4xl mb-3">❌</div>
|
|
||||||
<p class="text-lg text-red font-medium">Proposal Rejected</p>
|
|
||||||
<p class="text-sm text-subtext1 mt-1">
|
|
||||||
The proposed time was rejected. A new time needs to be proposed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
} else if current.Status == db.ScheduleStatusCancelled {
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<div class="text-4xl mb-3">🚫</div>
|
|
||||||
<p class="text-lg text-red font-medium">Schedule Cancelled</p>
|
|
||||||
if current.RescheduleReason != nil {
|
|
||||||
<p class="text-sm text-subtext1 mt-1">
|
|
||||||
{ *current.RescheduleReason }
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} else if current.Status == db.ScheduleStatusRescheduled {
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<div class="text-4xl mb-3">🔄</div>
|
|
||||||
<p class="text-lg text-yellow font-medium">Rescheduled</p>
|
|
||||||
if current.RescheduleReason != nil {
|
|
||||||
<p class="text-sm text-subtext1 mt-1">
|
|
||||||
Reason: { *current.RescheduleReason }
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
<p class="text-sm text-subtext1 mt-1">
|
|
||||||
A new time needs to be proposed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
} else if current.Status == db.ScheduleStatusPostponed {
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<div class="text-4xl mb-3">⏸️</div>
|
|
||||||
<p class="text-lg text-peach font-medium">Postponed</p>
|
|
||||||
if current.RescheduleReason != nil {
|
|
||||||
<p class="text-sm text-subtext1 mt-1">
|
|
||||||
Reason: { *current.RescheduleReason }
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
<p class="text-sm text-subtext1 mt-1">
|
|
||||||
A new time needs to be proposed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
} else if current.Status == db.ScheduleStatusWithdrawn {
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<div class="text-4xl mb-3">↩️</div>
|
|
||||||
<p class="text-lg text-subtext0 font-medium">Proposal Withdrawn</p>
|
|
||||||
<p class="text-sm text-subtext1 mt-1">
|
|
||||||
The proposed time was withdrawn. A new time needs to be proposed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesScheduleActions(
|
|
||||||
series *db.PlayoffSeries,
|
|
||||||
current *db.PlayoffSeriesSchedule,
|
|
||||||
canSchedule bool,
|
|
||||||
canManage bool,
|
|
||||||
userTeamID int,
|
|
||||||
) {
|
|
||||||
{{
|
|
||||||
bothTeamsAssigned := series.Team1ID != nil && series.Team2ID != nil
|
|
||||||
|
|
||||||
showPropose := false
|
|
||||||
showReschedule := false
|
|
||||||
showPostpone := false
|
|
||||||
showCancel := false
|
|
||||||
|
|
||||||
if bothTeamsAssigned && canSchedule {
|
|
||||||
if current == nil {
|
|
||||||
showPropose = true
|
|
||||||
} else if current.Status == db.ScheduleStatusRejected {
|
|
||||||
showPropose = true
|
|
||||||
} else if current.Status == db.ScheduleStatusRescheduled {
|
|
||||||
showPropose = true
|
|
||||||
} else if current.Status == db.ScheduleStatusPostponed {
|
|
||||||
showPropose = true
|
|
||||||
} else if current.Status == db.ScheduleStatusWithdrawn {
|
|
||||||
showPropose = true
|
|
||||||
} else if current.Status == db.ScheduleStatusAccepted {
|
|
||||||
showReschedule = true
|
|
||||||
showPostpone = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bothTeamsAssigned && canManage && current != nil && !current.Status.IsTerminal() {
|
|
||||||
showCancel = true
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
if showPropose || showReschedule || showPostpone || showCancel {
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<!-- Propose Time -->
|
|
||||||
if showPropose {
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
|
||||||
<h3 class="text-md font-bold text-text">Propose Time</h3>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<form
|
|
||||||
hx-post={ fmt.Sprintf("/series/%d/schedule", series.ID) }
|
|
||||||
hx-swap="none"
|
|
||||||
class="space-y-4"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-subtext0 mb-1">
|
|
||||||
Date & Time
|
|
||||||
<span class="relative group inline-block ml-1">
|
|
||||||
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium cursor-help">AEST/AEDT</span>
|
|
||||||
<span
|
|
||||||
class="absolute left-0 top-full mt-1 w-56 bg-crust border border-surface1 rounded-lg
|
|
||||||
p-2 text-xs text-subtext0 shadow-lg opacity-0 invisible
|
|
||||||
group-hover:opacity-100 group-hover:visible transition-all z-50"
|
|
||||||
>
|
|
||||||
Enter the time in Australian Eastern time (AEST/AEDT). It will be displayed in each user's local timezone.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
name="scheduled_time"
|
|
||||||
required
|
|
||||||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
|
||||||
focus:border-blue focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
|
||||||
font-medium transition hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
Propose Time
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<!-- Reschedule -->
|
|
||||||
if showReschedule {
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
|
||||||
<h3 class="text-md font-bold text-text">Reschedule</h3>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<form
|
|
||||||
hx-post={ fmt.Sprintf("/series/%d/schedule/reschedule", series.ID) }
|
|
||||||
hx-swap="none"
|
|
||||||
class="space-y-4"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-subtext0 mb-1">
|
|
||||||
New Date & Time
|
|
||||||
<span class="relative group inline-block ml-1">
|
|
||||||
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium cursor-help">AEST/AEDT</span>
|
|
||||||
<span
|
|
||||||
class="absolute left-0 top-full mt-1 w-56 bg-crust border border-surface1 rounded-lg
|
|
||||||
p-2 text-xs text-subtext0 shadow-lg opacity-0 invisible
|
|
||||||
group-hover:opacity-100 group-hover:visible transition-all z-50"
|
|
||||||
>
|
|
||||||
Enter the time in Australian Eastern time (AEST/AEDT). It will be displayed in each user's local timezone.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
name="scheduled_time"
|
|
||||||
required
|
|
||||||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
|
||||||
focus:border-blue focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
|
|
||||||
@seriesRescheduleReasonSelect(series)
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full px-4 py-2 bg-yellow hover:bg-yellow/80 text-mantle rounded-lg
|
|
||||||
font-medium transition hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
Reschedule
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<!-- Postpone -->
|
|
||||||
if showPostpone {
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
|
||||||
<h3 class="text-md font-bold text-text">Postpone</h3>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
|
|
||||||
@seriesRescheduleReasonSelect(series)
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("if (!this.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Postpone Series', message: 'Are you sure you want to postpone this series? The current schedule will be cleared.', action: () => htmx.ajax('POST', '/series/%d/schedule/postpone', { swap: 'none', values: { reschedule_reason: this.parentElement.querySelector('select').value } }) } }))", series.ID)) }
|
|
||||||
class="w-full px-4 py-2 bg-peach hover:bg-peach/80 text-mantle rounded-lg
|
|
||||||
font-medium transition hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
Postpone Series
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<!-- Cancel (moderator only) -->
|
|
||||||
if showCancel {
|
|
||||||
<div class="bg-mantle border border-red/30 rounded-lg">
|
|
||||||
<div class="bg-red/10 border-b border-red/30 px-4 py-3 rounded-t-lg">
|
|
||||||
<h3 class="text-md font-bold text-red">Cancel Schedule</h3>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<p class="text-xs text-red/80 mb-3 font-medium">
|
|
||||||
This action will cancel the current series schedule.
|
|
||||||
</p>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="reschedule_reason"
|
|
||||||
placeholder="Enter reason..."
|
|
||||||
required
|
|
||||||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
|
||||||
focus:border-blue focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("var reason = this.parentElement.querySelector('input[name=reschedule_reason]').value; if (!reason) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Cancel Schedule', message: 'Are you sure you want to cancel this schedule?', action: () => htmx.ajax('POST', '/series/%d/schedule/cancel', { swap: 'none', values: { reschedule_reason: reason } }) } }))", series.ID)) }
|
|
||||||
class="w-full px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
|
||||||
font-medium transition hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
Cancel Schedule
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
if !canSchedule && !canManage {
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
|
||||||
<p class="text-subtext1 text-sm">
|
|
||||||
Only team managers can manage series scheduling.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesRescheduleReasonSelect(series *db.PlayoffSeries) {
|
|
||||||
{{
|
|
||||||
team1Name := seriesTeamName(series.Team1)
|
|
||||||
team2Name := seriesTeamName(series.Team2)
|
|
||||||
}}
|
|
||||||
<select
|
|
||||||
name="reschedule_reason"
|
|
||||||
required
|
|
||||||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
|
||||||
focus:border-blue focus:outline-none hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
<option value="" disabled selected>Select a reason</option>
|
|
||||||
<option value="Mutually Agreed">Mutually Agreed</option>
|
|
||||||
<option value={ fmt.Sprintf("%s Unavailable", team1Name) }>
|
|
||||||
{ team1Name } Unavailable
|
|
||||||
</option>
|
|
||||||
<option value={ fmt.Sprintf("%s Unavailable", team2Name) }>
|
|
||||||
{ team2Name } Unavailable
|
|
||||||
</option>
|
|
||||||
<option value={ fmt.Sprintf("%s No-show", team1Name) }>
|
|
||||||
{ team1Name } No-show
|
|
||||||
</option>
|
|
||||||
<option value={ fmt.Sprintf("%s No-show", team2Name) }>
|
|
||||||
{ team2Name } No-show
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesScheduleHistory(series *db.PlayoffSeries, history []*db.PlayoffSeriesSchedule) {
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">Schedule History</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
if len(history) == 0 {
|
|
||||||
<p class="text-subtext1 text-sm text-center py-4">No scheduling activity yet.</p>
|
|
||||||
} else {
|
|
||||||
<div class="space-y-3">
|
|
||||||
for i := len(history) - 1; i >= 0; i-- {
|
|
||||||
@seriesScheduleHistoryItem(history[i], i == len(history)-1)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ seriesScheduleHistoryItem(schedule *db.PlayoffSeriesSchedule, isCurrent bool) {
|
|
||||||
{{
|
|
||||||
statusColor := "text-subtext0"
|
|
||||||
statusBg := "bg-surface1"
|
|
||||||
statusLabel := string(schedule.Status)
|
|
||||||
switch schedule.Status {
|
|
||||||
case db.ScheduleStatusPending:
|
|
||||||
statusColor = "text-blue"
|
|
||||||
statusBg = "bg-blue/20"
|
|
||||||
statusLabel = "Pending"
|
|
||||||
case db.ScheduleStatusAccepted:
|
|
||||||
statusColor = "text-green"
|
|
||||||
statusBg = "bg-green/20"
|
|
||||||
statusLabel = "Accepted"
|
|
||||||
case db.ScheduleStatusRejected:
|
|
||||||
statusColor = "text-red"
|
|
||||||
statusBg = "bg-red/20"
|
|
||||||
statusLabel = "Rejected"
|
|
||||||
case db.ScheduleStatusRescheduled:
|
|
||||||
statusColor = "text-yellow"
|
|
||||||
statusBg = "bg-yellow/20"
|
|
||||||
statusLabel = "Rescheduled"
|
|
||||||
case db.ScheduleStatusPostponed:
|
|
||||||
statusColor = "text-peach"
|
|
||||||
statusBg = "bg-peach/20"
|
|
||||||
statusLabel = "Postponed"
|
|
||||||
case db.ScheduleStatusCancelled:
|
|
||||||
statusColor = "text-red"
|
|
||||||
statusBg = "bg-red/20"
|
|
||||||
statusLabel = "Cancelled"
|
|
||||||
case db.ScheduleStatusWithdrawn:
|
|
||||||
statusColor = "text-subtext0"
|
|
||||||
statusBg = "bg-surface1"
|
|
||||||
statusLabel = "Withdrawn"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
<div class={ "border rounded-lg p-3", templ.KV("border-surface1", !isCurrent), templ.KV("border-blue/30 bg-blue/5", isCurrent) }>
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
if isCurrent {
|
|
||||||
<span class="text-xs px-1.5 py-0.5 bg-blue/20 text-blue rounded font-medium">
|
|
||||||
CURRENT
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<span class={ "text-xs px-2 py-0.5 rounded font-medium", statusBg, statusColor }>
|
|
||||||
{ statusLabel }
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-subtext1">
|
|
||||||
@localtimeUnix(schedule.CreatedAt, "histdate")
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="flex items-center gap-2 text-sm">
|
|
||||||
<span class="text-subtext0">Proposed by:</span>
|
|
||||||
<span class="text-text font-medium">{ schedule.ProposedBy.Name }</span>
|
|
||||||
</div>
|
|
||||||
if schedule.ScheduledTime != nil {
|
|
||||||
<div class="flex items-center gap-2 text-sm">
|
|
||||||
<span class="text-subtext0">Time:</span>
|
|
||||||
<span class="text-text">
|
|
||||||
@localtime(schedule.ScheduledTime, "datetime")
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class="flex items-center gap-2 text-sm">
|
|
||||||
<span class="text-subtext0">Time:</span>
|
|
||||||
<span class="text-subtext1 italic">No time set</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
if schedule.RescheduleReason != nil {
|
|
||||||
<div class="flex items-center gap-2 text-sm">
|
|
||||||
<span class="text-subtext0">Reason:</span>
|
|
||||||
<span class="text-subtext1">{ *schedule.RescheduleReason }</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
package seasonsview
|
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
templ SeriesUploadResultPage(series *db.PlayoffSeries) {
|
|
||||||
{{
|
|
||||||
backURL := fmt.Sprintf("/series/%d", series.ID)
|
|
||||||
team1Name := seriesTeamName(series.Team1)
|
|
||||||
team2Name := seriesTeamName(series.Team2)
|
|
||||||
boLabel := fmt.Sprintf("BO%d", series.MatchesToWin*2-1)
|
|
||||||
maxGames := series.MatchesToWin*2 - 1
|
|
||||||
minGames := series.MatchesToWin
|
|
||||||
}}
|
|
||||||
@baseview.Layout(fmt.Sprintf("Upload Series Result — %s vs %s", team1Name, team2Name)) {
|
|
||||||
<div class="max-w-screen-md mx-auto px-4 py-8">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-6 py-6">
|
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold text-text mb-1">Upload Series Results</h1>
|
|
||||||
<p class="text-sm text-subtext1">
|
|
||||||
{ team1Name } vs { team2Name }
|
|
||||||
<span class="text-subtext0 ml-1">
|
|
||||||
{ series.Label } · { boLabel }
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={ templ.SafeURL(backURL) }
|
|
||||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
|
||||||
bg-surface1 hover:bg-surface2 text-text transition"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Upload Form -->
|
|
||||||
<div
|
|
||||||
class="bg-mantle border border-surface1 rounded-lg overflow-hidden"
|
|
||||||
x-data={ fmt.Sprintf("{ gameCount: %d }", minGames) }
|
|
||||||
>
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
|
||||||
<h2 class="text-lg font-bold text-text">Match Log Files</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<p class="text-sm text-subtext1 mb-6">
|
|
||||||
Upload the 3 period match log JSON files for each game in the series.
|
|
||||||
Select the number of games that were actually played.
|
|
||||||
</p>
|
|
||||||
<form
|
|
||||||
hx-post={ fmt.Sprintf("/series/%d/results/upload", series.ID) }
|
|
||||||
hx-swap="none"
|
|
||||||
hx-encoding="multipart/form-data"
|
|
||||||
class="space-y-6"
|
|
||||||
>
|
|
||||||
<!-- Game Count Selector -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-text mb-2">
|
|
||||||
Number of Games Played
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="game_count"
|
|
||||||
x-model="gameCount"
|
|
||||||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
|
||||||
focus:border-blue focus:outline-none hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
for g := minGames; g <= maxGames; g++ {
|
|
||||||
<option
|
|
||||||
value={ fmt.Sprint(g) }
|
|
||||||
if g == minGames {
|
|
||||||
selected
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{ fmt.Sprint(g) } games
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-subtext0 mt-1">
|
|
||||||
First team to { fmt.Sprint(series.MatchesToWin) } wins takes the series
|
|
||||||
({ fmt.Sprint(minGames) }-{ fmt.Sprint(maxGames) } games possible)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<!-- Per-Game File Inputs -->
|
|
||||||
for g := 1; g <= maxGames; g++ {
|
|
||||||
<div
|
|
||||||
x-show={ fmt.Sprintf("gameCount >= %d", g) }
|
|
||||||
x-cloak
|
|
||||||
class="border border-surface1 rounded-lg overflow-hidden"
|
|
||||||
>
|
|
||||||
<div class="bg-surface0 border-b border-surface1 px-4 py-2">
|
|
||||||
<h3 class="text-md font-semibold text-text">Game { fmt.Sprint(g) }</h3>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 space-y-4">
|
|
||||||
for p := 1; p <= 3; p++ {
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-subtext0 mb-1">
|
|
||||||
Period { fmt.Sprint(p) }
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
name={ fmt.Sprintf("game_%d_period_%d", g, p) }
|
|
||||||
accept=".json"
|
|
||||||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
|
||||||
file:mr-4 file:py-1 file:px-3 file:rounded file:border-0
|
|
||||||
file:text-sm file:font-medium file:bg-blue file:text-mantle
|
|
||||||
file:hover:bg-blue/80 file:hover:cursor-pointer file:transition
|
|
||||||
focus:border-blue focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<!-- Submit -->
|
|
||||||
<div class="pt-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full px-4 py-3 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
|
||||||
font-medium transition hover:cursor-pointer text-lg"
|
|
||||||
>
|
|
||||||
Upload & Validate All Games
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user