added finals log uploads
This commit is contained in:
355
internal/db/playoff_results.go
Normal file
355
internal/db/playoff_results.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -327,6 +327,9 @@
|
|||||||
max-width: 96rem;
|
max-width: 96rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.mx-1 {
|
||||||
|
margin-inline: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
@@ -469,6 +472,9 @@
|
|||||||
.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);
|
||||||
}
|
}
|
||||||
@@ -673,6 +679,9 @@
|
|||||||
--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,);
|
||||||
}
|
}
|
||||||
@@ -757,6 +766,9 @@
|
|||||||
.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);
|
||||||
}
|
}
|
||||||
@@ -1581,6 +1593,11 @@
|
|||||||
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;
|
||||||
@@ -2419,6 +2436,16 @@
|
|||||||
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);
|
||||||
|
|||||||
502
internal/handlers/series_result.go
Normal file
502
internal/handlers/series_result.go
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -391,6 +391,32 @@ func addRoutes(
|
|||||||
Method: hws.MethodPOST,
|
Method: hws.MethodPOST,
|
||||||
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.CancelSeriesScheduleHandler(s, conn)),
|
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{
|
||||||
|
|||||||
@@ -216,9 +216,8 @@ templ SeriesDetailOverviewContent(
|
|||||||
{{
|
{{
|
||||||
permCache := contexts.Permissions(ctx)
|
permCache := contexts.Permissions(ctx)
|
||||||
canManage := permCache.HasPermission(permissions.PlayoffsManage)
|
canManage := permCache.HasPermission(permissions.PlayoffsManage)
|
||||||
_ = canManage
|
|
||||||
}}
|
}}
|
||||||
@seriesOverviewTab(series, currentSchedule, rosters, canSchedule, userTeamID)
|
@seriesOverviewTab(series, currentSchedule, rosters, canSchedule, canManage, userTeamID)
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SeriesDetailPreviewContent(
|
templ SeriesDetailPreviewContent(
|
||||||
@@ -257,8 +256,15 @@ templ seriesOverviewTab(
|
|||||||
currentSchedule *db.PlayoffSeriesSchedule,
|
currentSchedule *db.PlayoffSeriesSchedule,
|
||||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
canSchedule bool,
|
canSchedule bool,
|
||||||
|
canManage bool,
|
||||||
userTeamID int,
|
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">
|
<div class="space-y-6">
|
||||||
<!-- Series Score + Schedule Row -->
|
<!-- Series Score + Schedule Row -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
@@ -270,6 +276,11 @@ templ seriesOverviewTab(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Prompt (for admins when series is in progress) -->
|
||||||
|
if showUploadPrompt {
|
||||||
|
@seriesUploadPrompt(series)
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Match List -->
|
<!-- Match List -->
|
||||||
if len(series.Matches) > 0 {
|
if len(series.Matches) > 0 {
|
||||||
@seriesMatchList(series)
|
@seriesMatchList(series)
|
||||||
@@ -290,6 +301,44 @@ templ seriesOverviewTab(
|
|||||||
</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) {
|
templ seriesScoreDisplay(series *db.PlayoffSeries) {
|
||||||
{{
|
{{
|
||||||
isCompleted := series.Status == db.SeriesStatusCompleted
|
isCompleted := series.Status == db.SeriesStatusCompleted
|
||||||
|
|||||||
374
internal/view/seasonsview/series_review_result.templ
Normal file
374
internal/view/seasonsview/series_review_result.templ
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
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"
|
||||||
|
@click={ 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>
|
||||||
|
}
|
||||||
133
internal/view/seasonsview/series_upload_result.templ
Normal file
133
internal/view/seasonsview/series_upload_result.templ
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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