added log file uploading and match results
This commit is contained in:
363
internal/db/fixture_result.go
Normal file
363
internal/db/fixture_result.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type FixtureResult struct {
|
||||
bun.BaseModel `bun:"table:fixture_results,alias:fr"`
|
||||
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
FixtureID int `bun:",notnull,unique"`
|
||||
Winner string `bun:",notnull"`
|
||||
HomeScore int `bun:",notnull"`
|
||||
AwayScore int `bun:",notnull"`
|
||||
MatchType string
|
||||
Arena string
|
||||
EndReason string
|
||||
PeriodsEnabled bool
|
||||
CustomMercyRule int
|
||||
MatchLength int
|
||||
CreatedAt int64 `bun:",notnull"`
|
||||
UpdatedAt *int64
|
||||
UploadedByUserID int `bun:",notnull"`
|
||||
Finalized bool `bun:",default:false"`
|
||||
TamperingDetected bool `bun:",default:false"`
|
||||
TamperingReason *string
|
||||
|
||||
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
|
||||
UploadedBy *User `bun:"rel:belongs-to,join:uploaded_by_user_id=id"`
|
||||
PlayerStats []*FixtureResultPlayerStats `bun:"rel:has-many,join:id=fixture_result_id"`
|
||||
}
|
||||
|
||||
type FixtureResultPlayerStats struct {
|
||||
bun.BaseModel `bun:"table:fixture_result_player_stats,alias:frps"`
|
||||
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
FixtureResultID int `bun:",notnull"`
|
||||
PeriodNum int `bun:",notnull"`
|
||||
PlayerID *int // NULL for unmapped/free agents
|
||||
PlayerGameUserID string `bun:",notnull"`
|
||||
PlayerUsername string `bun:",notnull"`
|
||||
TeamID *int // NULL for unmapped
|
||||
Team string `bun:",notnull"` // 'home' or 'away'
|
||||
|
||||
// All stats as INT (nullable)
|
||||
Goals *int
|
||||
Assists *int
|
||||
PrimaryAssists *int
|
||||
SecondaryAssists *int
|
||||
Saves *int
|
||||
Blocks *int
|
||||
Shots *int
|
||||
Turnovers *int
|
||||
Takeaways *int
|
||||
Passes *int
|
||||
PossessionTimeSec *int
|
||||
FaceoffsWon *int
|
||||
FaceoffsLost *int
|
||||
PostHits *int
|
||||
OvertimeGoals *int
|
||||
GameWinningGoals *int
|
||||
Score *int
|
||||
ContributedGoals *int
|
||||
ConcededGoals *int
|
||||
GamesPlayed *int
|
||||
Wins *int
|
||||
Losses *int
|
||||
OvertimeWins *int
|
||||
OvertimeLosses *int
|
||||
Ties *int
|
||||
Shutouts *int
|
||||
ShutoutsAgainst *int
|
||||
HasMercyRuled *int
|
||||
WasMercyRuled *int
|
||||
PeriodsPlayed *int
|
||||
|
||||
FixtureResult *FixtureResult `bun:"rel:belongs-to,join:fixture_result_id=id"`
|
||||
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
|
||||
TeamRel *Team `bun:"rel:belongs-to,join:team_id=id"`
|
||||
}
|
||||
|
||||
// PlayerWithPlayStatus is a helper struct for overview display
|
||||
type PlayerWithPlayStatus struct {
|
||||
Player *Player
|
||||
Played bool
|
||||
IsManager bool
|
||||
Stats *FixtureResultPlayerStats // Period 3 (final cumulative) stats, nil if no result
|
||||
}
|
||||
|
||||
// InsertFixtureResult stores a new match result with all player stats in a single transaction.
|
||||
func InsertFixtureResult(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
result *FixtureResult,
|
||||
playerStats []*FixtureResultPlayerStats,
|
||||
audit *AuditMeta,
|
||||
) (*FixtureResult, error) {
|
||||
if result == nil {
|
||||
return nil, errors.New("result cannot be nil")
|
||||
}
|
||||
|
||||
result.CreatedAt = time.Now().Unix()
|
||||
|
||||
err := Insert(tx, result).WithAudit(audit, &AuditInfo{
|
||||
Action: "fixture_results.create",
|
||||
ResourceType: "fixture_result",
|
||||
ResourceID: nil,
|
||||
Details: map[string]any{
|
||||
"fixture_id": result.FixtureID,
|
||||
"winner": result.Winner,
|
||||
"home_score": result.HomeScore,
|
||||
"away_score": result.AwayScore,
|
||||
"tampering_detected": result.TamperingDetected,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Insert result")
|
||||
}
|
||||
|
||||
// Set the fixture_result_id on all player stats
|
||||
for _, ps := range playerStats {
|
||||
ps.FixtureResultID = result.ID
|
||||
}
|
||||
|
||||
// Insert player stats in bulk
|
||||
if len(playerStats) > 0 {
|
||||
err = InsertMultiple(tx, playerStats).Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "InsertMultiple player stats")
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetFixtureResult retrieves a result with all player stats for a fixture.
|
||||
// Returns nil, nil if no result exists.
|
||||
func GetFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) {
|
||||
result := new(FixtureResult)
|
||||
err := tx.NewSelect().
|
||||
Model(result).
|
||||
Where("fr.fixture_id = ?", fixtureID).
|
||||
Relation("Fixture").
|
||||
Relation("UploadedBy").
|
||||
Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC")
|
||||
}).
|
||||
Relation("PlayerStats.Player").
|
||||
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 result, nil
|
||||
}
|
||||
|
||||
// GetPendingFixtureResult retrieves a non-finalized result for review/edit.
|
||||
// Returns nil, nil if no pending result exists.
|
||||
func GetPendingFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) {
|
||||
result := new(FixtureResult)
|
||||
err := tx.NewSelect().
|
||||
Model(result).
|
||||
Where("fr.fixture_id = ?", fixtureID).
|
||||
Where("fr.finalized = false").
|
||||
Relation("Fixture").
|
||||
Relation("UploadedBy").
|
||||
Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC")
|
||||
}).
|
||||
Relation("PlayerStats.Player").
|
||||
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 result, nil
|
||||
}
|
||||
|
||||
// FinalizeFixtureResult marks a pending result as finalized.
|
||||
func FinalizeFixtureResult(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixtureID int,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
result, err := GetPendingFixtureResult(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetPendingFixtureResult")
|
||||
}
|
||||
if result == nil {
|
||||
return BadRequest("no pending result to finalize")
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
result.Finalized = true
|
||||
result.UpdatedAt = &now
|
||||
|
||||
err = UpdateByID(tx, result.ID, result).
|
||||
Column("finalized", "updated_at").
|
||||
WithAudit(audit, &AuditInfo{
|
||||
Action: "fixture_results.finalize",
|
||||
ResourceType: "fixture_result",
|
||||
ResourceID: result.ID,
|
||||
Details: map[string]any{
|
||||
"fixture_id": fixtureID,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteFixtureResult removes a pending result and all associated player stats (CASCADE).
|
||||
func DeleteFixtureResult(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixtureID int,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
result, err := GetPendingFixtureResult(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetPendingFixtureResult")
|
||||
}
|
||||
if result == nil {
|
||||
return BadRequest("no pending result to discard")
|
||||
}
|
||||
|
||||
err = DeleteByID[FixtureResult](tx, result.ID).
|
||||
WithAudit(audit, &AuditInfo{
|
||||
Action: "fixture_results.discard",
|
||||
ResourceType: "fixture_result",
|
||||
ResourceID: result.ID,
|
||||
Details: map[string]any{
|
||||
"fixture_id": fixtureID,
|
||||
},
|
||||
}).Delete(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "DeleteByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFinalizedResultsForFixtures returns finalized results for a list of fixture IDs.
|
||||
// Returns a map of fixtureID -> *FixtureResult (without player stats for efficiency).
|
||||
func GetFinalizedResultsForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs []int) (map[int]*FixtureResult, error) {
|
||||
if len(fixtureIDs) == 0 {
|
||||
return map[int]*FixtureResult{}, nil
|
||||
}
|
||||
results, err := GetList[FixtureResult](tx).
|
||||
Where("fixture_id IN (?)", bun.In(fixtureIDs)).
|
||||
Where("finalized = true").
|
||||
GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetList")
|
||||
}
|
||||
resultMap := make(map[int]*FixtureResult, len(results))
|
||||
for _, r := range results {
|
||||
resultMap[r.FixtureID] = r
|
||||
}
|
||||
return resultMap, nil
|
||||
}
|
||||
|
||||
// GetFixtureTeamRosters returns all team players with participation status for a fixture.
|
||||
// Returns: map["home"|"away"] -> []*PlayerWithPlayStatus
|
||||
func GetFixtureTeamRosters(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixture *Fixture,
|
||||
result *FixtureResult,
|
||||
) (map[string][]*PlayerWithPlayStatus, error) {
|
||||
if fixture == nil {
|
||||
return nil, errors.New("fixture cannot be nil")
|
||||
}
|
||||
|
||||
rosters := map[string][]*PlayerWithPlayStatus{}
|
||||
|
||||
// Get home team roster
|
||||
homeRosters := []*TeamRoster{}
|
||||
err := tx.NewSelect().
|
||||
Model(&homeRosters).
|
||||
Where("tr.team_id = ?", fixture.HomeTeamID).
|
||||
Where("tr.season_id = ?", fixture.SeasonID).
|
||||
Where("tr.league_id = ?", fixture.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 home roster")
|
||||
}
|
||||
|
||||
// Get away team roster
|
||||
awayRosters := []*TeamRoster{}
|
||||
err = tx.NewSelect().
|
||||
Model(&awayRosters).
|
||||
Where("tr.team_id = ?", fixture.AwayTeamID).
|
||||
Where("tr.season_id = ?", fixture.SeasonID).
|
||||
Where("tr.league_id = ?", fixture.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 away roster")
|
||||
}
|
||||
|
||||
// Build maps of player IDs that played and their period 3 stats
|
||||
playedPlayerIDs := map[int]bool{}
|
||||
playerStatsByID := map[int]*FixtureResultPlayerStats{}
|
||||
if result != nil {
|
||||
for _, ps := range result.PlayerStats {
|
||||
if ps.PlayerID != nil {
|
||||
playedPlayerIDs[*ps.PlayerID] = true
|
||||
if ps.PeriodNum == 3 {
|
||||
playerStatsByID[*ps.PlayerID] = ps
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build home roster with play status and stats
|
||||
for _, r := range homeRosters {
|
||||
played := false
|
||||
var stats *FixtureResultPlayerStats
|
||||
if result != nil && r.Player != nil {
|
||||
played = playedPlayerIDs[r.Player.ID]
|
||||
stats = playerStatsByID[r.Player.ID]
|
||||
}
|
||||
rosters["home"] = append(rosters["home"], &PlayerWithPlayStatus{
|
||||
Player: r.Player,
|
||||
Played: played,
|
||||
IsManager: r.IsManager,
|
||||
Stats: stats,
|
||||
})
|
||||
}
|
||||
|
||||
// Build away roster with play status and stats
|
||||
for _, r := range awayRosters {
|
||||
played := false
|
||||
var stats *FixtureResultPlayerStats
|
||||
if result != nil && r.Player != nil {
|
||||
played = playedPlayerIDs[r.Player.ID]
|
||||
stats = playerStatsByID[r.Player.ID]
|
||||
}
|
||||
rosters["away"] = append(rosters["away"], &PlayerWithPlayStatus{
|
||||
Player: r.Player,
|
||||
Played: played,
|
||||
IsManager: r.IsManager,
|
||||
Stats: stats,
|
||||
})
|
||||
}
|
||||
|
||||
return rosters, nil
|
||||
}
|
||||
91
internal/db/migrations/20260221140000_fixture_results.go
Normal file
91
internal/db/migrations/20260221140000_fixture_results.go
Normal file
@@ -0,0 +1,91 @@
|
||||
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 {
|
||||
// Create fixture_results table
|
||||
_, err := conn.NewCreateTable().
|
||||
Model((*db.FixtureResult)(nil)).
|
||||
IfNotExists().
|
||||
ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`).
|
||||
ForeignKey(`("uploaded_by_user_id") REFERENCES "users" ("id")`).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create fixture_result_player_stats table
|
||||
_, err = conn.NewCreateTable().
|
||||
Model((*db.FixtureResultPlayerStats)(nil)).
|
||||
IfNotExists().
|
||||
ForeignKey(`("fixture_result_id") REFERENCES "fixture_results" ("id") ON DELETE CASCADE`).
|
||||
ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE SET NULL`).
|
||||
ForeignKey(`("team_id") REFERENCES "teams" ("id") ON DELETE SET NULL`).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create index on fixture_result_id for faster lookups
|
||||
_, err = conn.NewCreateIndex().
|
||||
Model((*db.FixtureResultPlayerStats)(nil)).
|
||||
Index("idx_frps_fixture_result_id").
|
||||
Column("fixture_result_id").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create index on player_id for stats queries
|
||||
_, err = conn.NewCreateIndex().
|
||||
Model((*db.FixtureResultPlayerStats)(nil)).
|
||||
Index("idx_frps_player_id").
|
||||
Column("player_id").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create composite index for period+team filtering
|
||||
_, err = conn.NewCreateIndex().
|
||||
Model((*db.FixtureResultPlayerStats)(nil)).
|
||||
Index("idx_frps_result_period_team").
|
||||
Column("fixture_result_id", "period_num", "team").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
// DOWN migration
|
||||
func(ctx context.Context, conn *bun.DB) error {
|
||||
// Drop fixture_result_player_stats first (has FK to fixture_results)
|
||||
_, err := conn.NewDropTable().
|
||||
Model((*db.FixtureResultPlayerStats)(nil)).
|
||||
IfExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop fixture_results
|
||||
_, err = conn.NewDropTable().
|
||||
Model((*db.FixtureResult)(nil)).
|
||||
IfExists().
|
||||
Exec(ctx)
|
||||
return err
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,10 @@ func (db *DB) RegisterModels() []any {
|
||||
(*Permission)(nil),
|
||||
(*AuditLog)(nil),
|
||||
(*Fixture)(nil),
|
||||
(*FixtureSchedule)(nil),
|
||||
(*Player)(nil),
|
||||
(*FixtureResult)(nil),
|
||||
(*FixtureResultPlayerStats)(nil),
|
||||
}
|
||||
db.RegisterModel(models...)
|
||||
return models
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
--spacing: 0.25rem;
|
||||
--breakpoint-md: 48rem;
|
||||
--breakpoint-lg: 64rem;
|
||||
--breakpoint-xl: 80rem;
|
||||
--breakpoint-2xl: 96rem;
|
||||
@@ -38,6 +39,7 @@
|
||||
--text-6xl--line-height: 1;
|
||||
--text-9xl: 8rem;
|
||||
--text-9xl--line-height: 1;
|
||||
--font-weight-light: 300;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
@@ -50,6 +52,7 @@
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--animate-spin: spin 1s linear infinite;
|
||||
--blur-sm: 8px;
|
||||
--default-transition-duration: 150ms;
|
||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--default-font-family: var(--font-sans);
|
||||
@@ -214,6 +217,9 @@
|
||||
.collapse {
|
||||
visibility: collapse;
|
||||
}
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
@@ -261,6 +267,9 @@
|
||||
.top-20 {
|
||||
top: calc(var(--spacing) * 20);
|
||||
}
|
||||
.top-full {
|
||||
top: 100%;
|
||||
}
|
||||
.right-0 {
|
||||
right: calc(var(--spacing) * 0);
|
||||
}
|
||||
@@ -451,6 +460,9 @@
|
||||
.h-16 {
|
||||
height: calc(var(--spacing) * 16);
|
||||
}
|
||||
.h-\[calc\(100\%-3rem\)\] {
|
||||
height: calc(100% - 3rem);
|
||||
}
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -514,6 +526,12 @@
|
||||
.w-48 {
|
||||
width: calc(var(--spacing) * 48);
|
||||
}
|
||||
.w-56 {
|
||||
width: calc(var(--spacing) * 56);
|
||||
}
|
||||
.w-72 {
|
||||
width: calc(var(--spacing) * 72);
|
||||
}
|
||||
.w-80 {
|
||||
width: calc(var(--spacing) * 80);
|
||||
}
|
||||
@@ -559,6 +577,9 @@
|
||||
.max-w-screen-lg {
|
||||
max-width: var(--breakpoint-lg);
|
||||
}
|
||||
.max-w-screen-md {
|
||||
max-width: var(--breakpoint-md);
|
||||
}
|
||||
.max-w-screen-xl {
|
||||
max-width: var(--breakpoint-xl);
|
||||
}
|
||||
@@ -610,6 +631,9 @@
|
||||
.cursor-grab {
|
||||
cursor: grab;
|
||||
}
|
||||
.cursor-help {
|
||||
cursor: help;
|
||||
}
|
||||
.cursor-not-allowed {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -622,6 +646,12 @@
|
||||
.resize-none {
|
||||
resize: none;
|
||||
}
|
||||
.list-inside {
|
||||
list-style-position: inside;
|
||||
}
|
||||
.list-disc {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.appearance-none {
|
||||
appearance: none;
|
||||
}
|
||||
@@ -667,6 +697,9 @@
|
||||
.gap-1 {
|
||||
gap: calc(var(--spacing) * 1);
|
||||
}
|
||||
.gap-1\.5 {
|
||||
gap: calc(var(--spacing) * 1.5);
|
||||
}
|
||||
.gap-2 {
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -682,6 +715,13 @@
|
||||
.gap-8 {
|
||||
gap: calc(var(--spacing) * 8);
|
||||
}
|
||||
.space-y-0\.5 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-block-start: calc(calc(var(--spacing) * 0.5) * var(--tw-space-y-reverse));
|
||||
margin-block-end: calc(calc(var(--spacing) * 0.5) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
.space-y-1 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
@@ -1036,6 +1076,9 @@
|
||||
.px-2 {
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
}
|
||||
.px-2\.5 {
|
||||
padding-inline: calc(var(--spacing) * 2.5);
|
||||
}
|
||||
.px-3 {
|
||||
padding-inline: calc(var(--spacing) * 3);
|
||||
}
|
||||
@@ -1054,6 +1097,9 @@
|
||||
.py-1 {
|
||||
padding-block: calc(var(--spacing) * 1);
|
||||
}
|
||||
.py-1\.5 {
|
||||
padding-block: calc(var(--spacing) * 1.5);
|
||||
}
|
||||
.py-2 {
|
||||
padding-block: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -1150,6 +1196,10 @@
|
||||
--tw-leading: calc(var(--spacing) * 6);
|
||||
line-height: calc(var(--spacing) * 6);
|
||||
}
|
||||
.leading-none {
|
||||
--tw-leading: 1;
|
||||
line-height: 1;
|
||||
}
|
||||
.leading-relaxed {
|
||||
--tw-leading: var(--leading-relaxed);
|
||||
line-height: var(--leading-relaxed);
|
||||
@@ -1158,6 +1208,10 @@
|
||||
--tw-font-weight: var(--font-weight-bold);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
.font-light {
|
||||
--tw-font-weight: var(--font-weight-light);
|
||||
font-weight: var(--font-weight-light);
|
||||
}
|
||||
.font-medium {
|
||||
--tw-font-weight: var(--font-weight-medium);
|
||||
font-weight: var(--font-weight-medium);
|
||||
@@ -1214,6 +1268,12 @@
|
||||
.text-red {
|
||||
color: var(--red);
|
||||
}
|
||||
.text-red\/60 {
|
||||
color: var(--red);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, var(--red) 60%, transparent);
|
||||
}
|
||||
}
|
||||
.text-red\/80 {
|
||||
color: var(--red);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -1232,6 +1292,12 @@
|
||||
.text-yellow {
|
||||
color: var(--yellow);
|
||||
}
|
||||
.text-yellow\/70 {
|
||||
color: var(--yellow);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, var(--yellow) 70%, transparent);
|
||||
}
|
||||
}
|
||||
.text-yellow\/80 {
|
||||
color: var(--yellow);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -1287,6 +1353,11 @@
|
||||
.filter {
|
||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||
}
|
||||
.backdrop-blur-sm {
|
||||
--tw-backdrop-blur: blur(var(--blur-sm));
|
||||
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||
}
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
@@ -1335,6 +1406,75 @@
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.group-hover\:visible {
|
||||
&:is(:where(.group):hover *) {
|
||||
@media (hover: hover) {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
.group-hover\:opacity-100 {
|
||||
&:is(:where(.group):hover *) {
|
||||
@media (hover: hover) {
|
||||
opacity: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.file\:mr-4 {
|
||||
&::file-selector-button {
|
||||
margin-right: calc(var(--spacing) * 4);
|
||||
}
|
||||
}
|
||||
.file\:rounded {
|
||||
&::file-selector-button {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
.file\:border-0 {
|
||||
&::file-selector-button {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 0px;
|
||||
}
|
||||
}
|
||||
.file\:bg-blue {
|
||||
&::file-selector-button {
|
||||
background-color: var(--blue);
|
||||
}
|
||||
}
|
||||
.file\:px-3 {
|
||||
&::file-selector-button {
|
||||
padding-inline: calc(var(--spacing) * 3);
|
||||
}
|
||||
}
|
||||
.file\:py-1 {
|
||||
&::file-selector-button {
|
||||
padding-block: calc(var(--spacing) * 1);
|
||||
}
|
||||
}
|
||||
.file\:text-sm {
|
||||
&::file-selector-button {
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
}
|
||||
}
|
||||
.file\:font-medium {
|
||||
&::file-selector-button {
|
||||
--tw-font-weight: var(--font-weight-medium);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
.file\:text-mantle {
|
||||
&::file-selector-button {
|
||||
color: var(--mantle);
|
||||
}
|
||||
}
|
||||
.file\:transition {
|
||||
&::file-selector-button {
|
||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
}
|
||||
.hover\:-translate-y-0\.5 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -1616,6 +1756,27 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.file\:hover\:cursor-pointer {
|
||||
&::file-selector-button {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.file\:hover\:bg-blue\/80 {
|
||||
&::file-selector-button {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--blue);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--blue) 80%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.focus\:border-blue {
|
||||
&:focus {
|
||||
border-color: var(--blue);
|
||||
@@ -2301,6 +2462,42 @@
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-backdrop-blur {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-backdrop-brightness {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-backdrop-contrast {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-backdrop-grayscale {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-backdrop-hue-rotate {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-backdrop-invert {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-backdrop-opacity {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-backdrop-saturate {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-backdrop-sepia {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-duration {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
@@ -2362,6 +2559,15 @@
|
||||
--tw-drop-shadow-color: initial;
|
||||
--tw-drop-shadow-alpha: 100%;
|
||||
--tw-drop-shadow-size: initial;
|
||||
--tw-backdrop-blur: initial;
|
||||
--tw-backdrop-brightness: initial;
|
||||
--tw-backdrop-contrast: initial;
|
||||
--tw-backdrop-grayscale: initial;
|
||||
--tw-backdrop-hue-rotate: initial;
|
||||
--tw-backdrop-invert: initial;
|
||||
--tw-backdrop-opacity: initial;
|
||||
--tw-backdrop-saturate: initial;
|
||||
--tw-backdrop-sepia: initial;
|
||||
--tw-duration: initial;
|
||||
--tw-ease: initial;
|
||||
}
|
||||
|
||||
84
internal/embedfs/web/js/localtime.js
Normal file
84
internal/embedfs/web/js/localtime.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// localtime.js - Converts UTC <time> elements to the user's local timezone.
|
||||
//
|
||||
// Usage: <time datetime="2026-01-14T10:30:00Z" data-localtime="datetime">fallback</time>
|
||||
//
|
||||
// Supported data-localtime values:
|
||||
// "date" → "Mon 2 Jan 2026"
|
||||
// "time" → "3:04 PM"
|
||||
// "datetime" → "Mon 2 Jan 2026 at 3:04 PM"
|
||||
// "short" → "Mon 2 Jan 3:04 PM"
|
||||
// "histdate" → "2 Jan 2006 15:04"
|
||||
|
||||
(function () {
|
||||
const SHORT_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const SHORT_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||||
|
||||
function formatTime12(d) {
|
||||
let h = d.getHours();
|
||||
const m = pad(d.getMinutes());
|
||||
const ampm = h >= 12 ? 'PM' : 'AM';
|
||||
h = h % 12 || 12;
|
||||
return h + ':' + m + ' ' + ampm;
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
return SHORT_DAYS[d.getDay()] + ' ' + d.getDate() + ' ' +
|
||||
SHORT_MONTHS[d.getMonth()] + ' ' + d.getFullYear();
|
||||
}
|
||||
|
||||
function formatLocalTime(el) {
|
||||
const iso = el.getAttribute('datetime');
|
||||
if (!iso) return;
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return;
|
||||
|
||||
const fmt = el.getAttribute('data-localtime');
|
||||
let text;
|
||||
switch (fmt) {
|
||||
case 'date':
|
||||
text = formatDate(d);
|
||||
break;
|
||||
case 'time':
|
||||
text = formatTime12(d);
|
||||
break;
|
||||
case 'datetime':
|
||||
text = formatDate(d) + ' at ' + formatTime12(d);
|
||||
break;
|
||||
case 'short':
|
||||
text = SHORT_DAYS[d.getDay()] + ' ' + d.getDate() + ' ' +
|
||||
SHORT_MONTHS[d.getMonth()] + ' ' + formatTime12(d);
|
||||
break;
|
||||
case 'histdate':
|
||||
text = d.getDate() + ' ' + SHORT_MONTHS[d.getMonth()] + ' ' +
|
||||
d.getFullYear() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
break;
|
||||
default:
|
||||
text = formatDate(d) + ' at ' + formatTime12(d);
|
||||
}
|
||||
el.textContent = text;
|
||||
|
||||
// Add timezone tooltip so users know the displayed time is in their local timezone
|
||||
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
el.title = 'Displayed in your local timezone (' + tz + ')';
|
||||
}
|
||||
|
||||
function processAll(root) {
|
||||
const els = (root || document).querySelectorAll('time[data-localtime]');
|
||||
els.forEach(formatLocalTime);
|
||||
}
|
||||
|
||||
// Process on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { processAll(); });
|
||||
} else {
|
||||
processAll();
|
||||
}
|
||||
|
||||
// Re-process after HTMX swaps
|
||||
document.addEventListener('htmx:afterSettle', function (evt) {
|
||||
processAll(evt.detail.elt);
|
||||
});
|
||||
})();
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// FixtureDetailPage renders the fixture detail page with scheduling UI and history
|
||||
// FixtureDetailPage renders the fixture detail page with scheduling UI, history,
|
||||
// result display, and team rosters
|
||||
func FixtureDetailPage(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
@@ -30,11 +31,18 @@ func FixtureDetailPage(
|
||||
return
|
||||
}
|
||||
|
||||
activeTab := r.URL.Query().Get("tab")
|
||||
if activeTab == "" {
|
||||
activeTab = "overview"
|
||||
}
|
||||
|
||||
var fixture *db.Fixture
|
||||
var currentSchedule *db.FixtureSchedule
|
||||
var history []*db.FixtureSchedule
|
||||
var canSchedule bool
|
||||
var userTeamID int
|
||||
var result *db.FixtureResult
|
||||
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
|
||||
@@ -59,6 +67,16 @@ func FixtureDetailPage(
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||
}
|
||||
// Fetch fixture result if it exists
|
||||
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetFixtureResult")
|
||||
}
|
||||
// Fetch team rosters with play status
|
||||
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
@@ -66,6 +84,7 @@ func FixtureDetailPage(
|
||||
|
||||
renderSafely(seasonsview.FixtureDetailPage(
|
||||
fixture, currentSchedule, history, canSchedule, userTeamID,
|
||||
result, rosters, activeTab,
|
||||
), s, r, w)
|
||||
})
|
||||
}
|
||||
@@ -88,7 +107,8 @@ func ProposeSchedule(
|
||||
}
|
||||
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
|
||||
DayNumeric2().T().Hour24().Colon().Minute().Build()
|
||||
scheduledTime := getter.Time("scheduled_time", format).After(time.Now()).Value
|
||||
aest, _ := time.LoadLocation("Australia/Sydney")
|
||||
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value
|
||||
|
||||
if !getter.ValidateAndNotify(s, w, r) {
|
||||
return
|
||||
@@ -319,7 +339,8 @@ func RescheduleFixture(
|
||||
}
|
||||
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
|
||||
DayNumeric2().T().Hour24().Colon().Minute().Build()
|
||||
scheduledTime := getter.Time("scheduled_time", format).After(time.Now()).Value
|
||||
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) {
|
||||
|
||||
415
internal/handlers/fixture_result.go
Normal file
415
internal/handlers/fixture_result.go
Normal file
@@ -0,0 +1,415 @@
|
||||
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"
|
||||
)
|
||||
|
||||
const maxUploadSize = 10 << 20 // 10 MB
|
||||
|
||||
// UploadMatchLogsPage renders the upload form for match log files
|
||||
func UploadMatchLogsPage(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
var fixture *db.Fixture
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
fixture, err = db.GetFixture(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.GetFixture")
|
||||
}
|
||||
// Check if result already exists
|
||||
existing, err := db.GetFixtureResult(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetFixtureResult")
|
||||
}
|
||||
if existing != nil {
|
||||
throw.BadRequest(s, w, r, "A result already exists for this fixture. Discard it first to re-upload.", nil)
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
renderSafely(seasonsview.FixtureUploadResultPage(fixture), s, r, w)
|
||||
})
|
||||
}
|
||||
|
||||
// UploadMatchLogs handles POST /fixtures/{fixture_id}/results/upload
|
||||
// Parses 3 multipart files, validates, detects tampering, and stores results.
|
||||
func UploadMatchLogs(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart form
|
||||
err = r.ParseMultipartForm(maxUploadSize)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Upload Failed", "Could not parse uploaded files. Ensure files are under 10MB.", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Read the 3 period files
|
||||
periodNames := []string{"period_1", "period_2", "period_3"}
|
||||
logs := make([]*slapshotapi.MatchLog, 3)
|
||||
for i, name := range periodNames {
|
||||
file, _, err := r.FormFile(name)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Missing File", "All 3 period files are required. Missing: "+name, 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: "+name, nil)
|
||||
return
|
||||
}
|
||||
|
||||
log, err := slapshotapi.ParseMatchLog(data)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Parse Error", "Could not parse "+name+": "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
logs[i] = log
|
||||
}
|
||||
|
||||
// Detect tampering
|
||||
tamperingDetected, tamperingReason, err := slapshotapi.DetectTampering(logs)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Validation Error", "Tampering check failed: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
var result *db.FixtureResult
|
||||
var unmappedPlayers []string
|
||||
|
||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
fixture, err := db.GetFixture(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.GetFixture")
|
||||
}
|
||||
|
||||
// Check if result already exists
|
||||
existing, err := db.GetFixtureResult(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetFixtureResult")
|
||||
}
|
||||
if existing != nil {
|
||||
notify.Warn(s, w, r, "Result Exists", "A result already exists for this fixture. Discard it first to re-upload.", nil)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Collect all unique game_user_ids across all periods
|
||||
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 game_user_ids to players
|
||||
playerLookup, err := MapGameUserIDsToPlayers(ctx, tx, gameUserIDs, fixture.SeasonID, fixture.LeagueID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "MapGameUserIDsToPlayers")
|
||||
}
|
||||
|
||||
// Determine team orientation using all players from all periods
|
||||
allPlayers := []slapshotapi.Player{}
|
||||
// Use period 3 players for orientation (most complete)
|
||||
allPlayers = append(allPlayers, logs[2].Players...)
|
||||
|
||||
fixtureHomeIsLogsHome, unmapped, err := DetermineTeamOrientation(ctx, tx, fixture, allPlayers, playerLookup)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Orientation Error",
|
||||
"Could not determine team orientation: "+err.Error()+". Please ensure players have registered Slapshot IDs.", nil)
|
||||
return false, nil
|
||||
}
|
||||
unmappedPlayers = unmapped
|
||||
|
||||
// Use period 3 (final) data for the result
|
||||
finalLog := logs[2]
|
||||
|
||||
// Determine winner in fixture terms
|
||||
winner := finalLog.Winner
|
||||
homeScore := finalLog.Score.Home
|
||||
awayScore := finalLog.Score.Away
|
||||
if !fixtureHomeIsLogsHome {
|
||||
// Logs are reversed - swap
|
||||
switch winner {
|
||||
case "home":
|
||||
winner = "away"
|
||||
case "away":
|
||||
winner = "home"
|
||||
}
|
||||
homeScore, awayScore = awayScore, homeScore
|
||||
}
|
||||
|
||||
// Parse metadata
|
||||
periodsEnabled := finalLog.PeriodsEnabled == "True"
|
||||
customMercyRule, _ := strconv.Atoi(finalLog.CustomMercyRule)
|
||||
matchLength, _ := strconv.Atoi(finalLog.MatchLength)
|
||||
|
||||
user := db.CurrentUser(ctx)
|
||||
|
||||
// Build result
|
||||
var tamperingReasonPtr *string
|
||||
if tamperingDetected {
|
||||
tamperingReasonPtr = &tamperingReason
|
||||
}
|
||||
|
||||
result = &db.FixtureResult{
|
||||
FixtureID: fixtureID,
|
||||
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 for all 3 periods
|
||||
playerStats := []*db.FixtureResultPlayerStats{}
|
||||
for periodIdx, log := range logs {
|
||||
periodNum := periodIdx + 1
|
||||
for _, p := range log.Players {
|
||||
// Determine team in fixture terms
|
||||
team := p.Team
|
||||
if !fixtureHomeIsLogsHome {
|
||||
if team == "home" {
|
||||
team = "away"
|
||||
} else {
|
||||
team = "home"
|
||||
}
|
||||
}
|
||||
|
||||
// Look up player
|
||||
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,
|
||||
// Convert float stats to int
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert result and stats
|
||||
result, err = db.InsertFixtureResult(ctx, tx, result, playerStats, db.NewAuditFromRequest(r))
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.InsertFixtureResult")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
_ = unmappedPlayers // stored for review page redirect
|
||||
notify.Success(s, w, r, "Logs Uploaded", "Match logs have been processed. Please review the result.", nil)
|
||||
respond.HXRedirect(w, "/fixtures/%d/results/review", fixtureID)
|
||||
})
|
||||
}
|
||||
|
||||
// ReviewMatchResult handles GET /fixtures/{fixture_id}/results/review
|
||||
func ReviewMatchResult(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
var fixture *db.Fixture
|
||||
var result *db.FixtureResult
|
||||
var unmappedPlayers []string
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
fixture, err = db.GetFixture(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.GetFixture")
|
||||
}
|
||||
|
||||
result, err = db.GetPendingFixtureResult(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPendingFixtureResult")
|
||||
}
|
||||
if result == nil {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Build unmapped players list from stats
|
||||
for _, ps := range result.PlayerStats {
|
||||
if ps.PlayerID == nil && ps.PeriodNum == 3 {
|
||||
unmappedPlayers = append(unmappedPlayers,
|
||||
ps.PlayerGameUserID+" ("+ps.PlayerUsername+")")
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
renderSafely(seasonsview.FixtureReviewResultPage(fixture, result, unmappedPlayers), s, r, w)
|
||||
})
|
||||
}
|
||||
|
||||
// FinalizeMatchResult handles POST /fixtures/{fixture_id}/results/finalize
|
||||
func FinalizeMatchResult(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
err := db.FinalizeFixtureResult(ctx, tx, fixtureID, 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.FinalizeFixtureResult")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
notify.SuccessWithDelay(s, w, r, "Result Finalized", "The match result has been finalized.", nil)
|
||||
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||
})
|
||||
}
|
||||
|
||||
// DiscardMatchResult handles POST /fixtures/{fixture_id}/results/discard
|
||||
func DiscardMatchResult(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
err := db.DeleteFixtureResult(ctx, tx, fixtureID, 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.DeleteFixtureResult")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
notify.Success(s, w, r, "Result Discarded", "The match result has been discarded. You can upload new logs.", nil)
|
||||
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||
})
|
||||
}
|
||||
187
internal/handlers/fixture_result_validation.go
Normal file
187
internal/handlers/fixture_result_validation.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// PlayerLookupResult stores the resolved player info from a game_user_id lookup
|
||||
type PlayerLookupResult struct {
|
||||
Player *db.Player
|
||||
TeamID int
|
||||
Found bool
|
||||
Unmapped bool // true if player not in system (potential free agent)
|
||||
}
|
||||
|
||||
// MapGameUserIDsToPlayers creates a lookup map from game_user_id to resolved player info.
|
||||
// It looks up players by their SlapID (which corresponds to game_user_id in match logs)
|
||||
// and checks their team assignment in the given season/league.
|
||||
func MapGameUserIDsToPlayers(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
gameUserIDs []string,
|
||||
seasonID, leagueID int,
|
||||
) (map[string]*PlayerLookupResult, error) {
|
||||
result := make(map[string]*PlayerLookupResult, len(gameUserIDs))
|
||||
|
||||
// Initialize all as unmapped
|
||||
for _, id := range gameUserIDs {
|
||||
result[id] = &PlayerLookupResult{Unmapped: true}
|
||||
}
|
||||
|
||||
if len(gameUserIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Get all players that have a slap_id matching any of the game_user_ids
|
||||
// game_user_id in logs is a string representation of the slapshot player ID (uint32)
|
||||
players := []*db.Player{}
|
||||
err := tx.NewSelect().
|
||||
Model(&players).
|
||||
Where("p.slap_id::text IN (?)", bun.In(gameUserIDs)).
|
||||
Relation("User").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect players")
|
||||
}
|
||||
|
||||
// Build a map of slapID -> player
|
||||
slapIDToPlayer := make(map[string]*db.Player, len(players))
|
||||
playerIDs := make([]int, 0, len(players))
|
||||
for _, p := range players {
|
||||
if p.SlapID != nil {
|
||||
key := slapIDStr(*p.SlapID)
|
||||
slapIDToPlayer[key] = p
|
||||
playerIDs = append(playerIDs, p.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Get team roster entries for these players in the given season/league
|
||||
rosters := []*db.TeamRoster{}
|
||||
if len(playerIDs) > 0 {
|
||||
err = tx.NewSelect().
|
||||
Model(&rosters).
|
||||
Where("tr.season_id = ?", seasonID).
|
||||
Where("tr.league_id = ?", leagueID).
|
||||
Where("tr.player_id IN (?)", bun.In(playerIDs)).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect rosters")
|
||||
}
|
||||
}
|
||||
|
||||
// Build playerID -> teamID map
|
||||
playerTeam := make(map[int]int, len(rosters))
|
||||
for _, r := range rosters {
|
||||
playerTeam[r.PlayerID] = r.TeamID
|
||||
}
|
||||
|
||||
// Populate results
|
||||
for _, id := range gameUserIDs {
|
||||
player, found := slapIDToPlayer[id]
|
||||
if !found {
|
||||
continue // stays unmapped
|
||||
}
|
||||
teamID, onTeam := playerTeam[player.ID]
|
||||
result[id] = &PlayerLookupResult{
|
||||
Player: player,
|
||||
TeamID: teamID,
|
||||
Found: true,
|
||||
Unmapped: !onTeam,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DetermineTeamOrientation validates that logs match fixture's team assignment
|
||||
// by cross-checking player game_user_ids against registered rosters.
|
||||
//
|
||||
// Returns:
|
||||
// - fixtureHomeIsLogsHome: true if fixture's home team maps to "home" in logs
|
||||
// - unmappedPlayers: list of game_user_ids that couldn't be resolved
|
||||
// - error: if orientation cannot be determined
|
||||
func DetermineTeamOrientation(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixture *db.Fixture,
|
||||
allPlayers []slapshotapi.Player,
|
||||
playerLookup map[string]*PlayerLookupResult,
|
||||
) (bool, []string, error) {
|
||||
if fixture == nil {
|
||||
return false, nil, errors.New("fixture cannot be nil")
|
||||
}
|
||||
|
||||
unmapped := []string{}
|
||||
|
||||
// Count how many fixture-home-team players are on "home" vs "away" in logs
|
||||
homeTeamOnHome := 0 // fixture home team players that are "home" in logs
|
||||
homeTeamOnAway := 0 // fixture home team players that are "away" in logs
|
||||
awayTeamOnHome := 0 // fixture away team players that are "home" in logs
|
||||
awayTeamOnAway := 0 // fixture away team players that are "away" in logs
|
||||
|
||||
for _, p := range allPlayers {
|
||||
lookup, exists := playerLookup[p.GameUserID]
|
||||
if !exists || lookup.Unmapped {
|
||||
unmapped = append(unmapped, p.GameUserID+" ("+p.Username+")")
|
||||
continue
|
||||
}
|
||||
|
||||
logTeam := p.Team // "home" or "away" in the log
|
||||
|
||||
switch lookup.TeamID {
|
||||
case fixture.HomeTeamID:
|
||||
if logTeam == "home" {
|
||||
homeTeamOnHome++
|
||||
} else {
|
||||
homeTeamOnAway++
|
||||
}
|
||||
case fixture.AwayTeamID:
|
||||
if logTeam == "home" {
|
||||
awayTeamOnHome++
|
||||
} else {
|
||||
awayTeamOnAway++
|
||||
}
|
||||
default:
|
||||
// Player is on a team but not one of the fixture teams
|
||||
unmapped = append(unmapped, p.GameUserID+" ("+p.Username+")")
|
||||
}
|
||||
}
|
||||
|
||||
totalMapped := homeTeamOnHome + homeTeamOnAway + awayTeamOnHome + awayTeamOnAway
|
||||
if totalMapped == 0 {
|
||||
return false, unmapped, errors.New("no mapped players found, cannot determine team orientation")
|
||||
}
|
||||
|
||||
// Calculate orientation: how many agree with "home=home" vs "home=away"
|
||||
matchOrientation := homeTeamOnHome + awayTeamOnAway // logs match fixture orientation
|
||||
reverseOrientation := homeTeamOnAway + awayTeamOnHome // logs are reversed
|
||||
|
||||
if matchOrientation == reverseOrientation {
|
||||
return false, unmapped, errors.New("cannot determine team orientation: equal evidence for both orientations")
|
||||
}
|
||||
|
||||
fixtureHomeIsLogsHome := matchOrientation > reverseOrientation
|
||||
return fixtureHomeIsLogsHome, unmapped, nil
|
||||
}
|
||||
|
||||
// FloatToIntPtr converts a *float64 to *int by truncating the decimal.
|
||||
// Returns nil if input is nil.
|
||||
func FloatToIntPtr(f *float64) *int {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
v := int(math.Round(*f))
|
||||
return &v
|
||||
}
|
||||
|
||||
// slapIDStr converts a uint32 SlapID to a string for map lookups
|
||||
func slapIDStr(id uint32) string {
|
||||
return fmt.Sprintf("%d", id)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func SeasonLeagueFixturesPage(
|
||||
var sl *db.SeasonLeague
|
||||
var fixtures []*db.Fixture
|
||||
var scheduleMap map[int]*db.FixtureSchedule
|
||||
var resultMap map[int]*db.FixtureResult
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
@@ -44,15 +45,19 @@ func SeasonLeagueFixturesPage(
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetAcceptedSchedulesForFixtures")
|
||||
}
|
||||
resultMap, err = db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "GET" {
|
||||
renderSafely(seasonsview.SeasonLeagueFixturesPage(sl.Season, sl.League, fixtures, scheduleMap), s, r, w)
|
||||
renderSafely(seasonsview.SeasonLeagueFixturesPage(sl.Season, sl.League, fixtures, scheduleMap, resultMap), s, r, w)
|
||||
} else {
|
||||
renderSafely(seasonsview.SeasonLeagueFixtures(sl.Season, sl.League, fixtures, scheduleMap), s, r, w)
|
||||
renderSafely(seasonsview.SeasonLeagueFixtures(sl.Season, sl.League, fixtures, scheduleMap, resultMap), s, r, w)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,6 +234,32 @@ func addRoutes(
|
||||
Method: hws.MethodPOST,
|
||||
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.CancelSchedule(s, conn)),
|
||||
},
|
||||
// Match result management routes (all require fixtures.manage permission)
|
||||
{
|
||||
Path: "/fixtures/{fixture_id}/results/upload",
|
||||
Method: hws.MethodGET,
|
||||
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.UploadMatchLogsPage(s, conn)),
|
||||
},
|
||||
{
|
||||
Path: "/fixtures/{fixture_id}/results/upload",
|
||||
Method: hws.MethodPOST,
|
||||
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.UploadMatchLogs(s, conn)),
|
||||
},
|
||||
{
|
||||
Path: "/fixtures/{fixture_id}/results/review",
|
||||
Method: hws.MethodGET,
|
||||
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.ReviewMatchResult(s, conn)),
|
||||
},
|
||||
{
|
||||
Path: "/fixtures/{fixture_id}/results/finalize",
|
||||
Method: hws.MethodPOST,
|
||||
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.FinalizeMatchResult(s, conn)),
|
||||
},
|
||||
{
|
||||
Path: "/fixtures/{fixture_id}/results/discard",
|
||||
Method: hws.MethodPOST,
|
||||
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.DiscardMatchResult(s, conn)),
|
||||
},
|
||||
}
|
||||
|
||||
teamRoutes := []hws.Route{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||
@@ -91,6 +92,10 @@ func (f *FormGetter) Time(key string, format *timefmt.Format) *TimeField {
|
||||
return newTimeField(key, format, f)
|
||||
}
|
||||
|
||||
func (f *FormGetter) TimeInLocation(key string, format *timefmt.Format, loc *time.Location) *TimeField {
|
||||
return newTimeFieldInLocation(key, format, loc, f)
|
||||
}
|
||||
|
||||
func (f *FormGetter) StringList(key string) *StringList {
|
||||
return newStringList(key, f)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,25 @@ func newTimeField(key string, format *timefmt.Format, g Getter) *TimeField {
|
||||
}
|
||||
}
|
||||
|
||||
func newTimeFieldInLocation(key string, format *timefmt.Format, loc *time.Location, g Getter) *TimeField {
|
||||
raw := g.Get(key)
|
||||
var startDate time.Time
|
||||
if raw != "" {
|
||||
var err error
|
||||
startDate, err = format.ParseInLocation(raw, loc)
|
||||
if err != nil {
|
||||
g.AddCheck(newFailedCheck(
|
||||
"Invalid date/time format",
|
||||
fmt.Sprintf("%s should be in format %s", key, format.LDML()),
|
||||
))
|
||||
}
|
||||
}
|
||||
return &TimeField{
|
||||
Value: startDate,
|
||||
FieldBase: newField(key, g),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TimeField) Required() *TimeField {
|
||||
if t.Value.IsZero() {
|
||||
t.getter.AddCheck(newFailedCheck(
|
||||
|
||||
@@ -26,6 +26,7 @@ templ Layout(title string) {
|
||||
<script src="/static/vendored/htmx@2.0.8.min.js"></script>
|
||||
<script src="/static/vendored/htmx-ext-ws.min.js"></script>
|
||||
<script src="/static/vendored/alpinejs@3.15.4.min.js" defer></script>
|
||||
<script src="/static/js/localtime.js" defer></script>
|
||||
if devInfo.HTMXLog {
|
||||
<script>
|
||||
htmx.logAll();
|
||||
|
||||
@@ -5,18 +5,8 @@ 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 "fmt"
|
||||
import "time"
|
||||
|
||||
func formatScheduleTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "No time set"
|
||||
}
|
||||
return t.Format("Mon 2 Jan 2006 at 3:04 PM")
|
||||
}
|
||||
|
||||
func formatHistoryTime(unix int64) string {
|
||||
return time.Unix(unix, 0).Format("2 Jan 2006 15:04")
|
||||
}
|
||||
import "sort"
|
||||
import "strings"
|
||||
|
||||
templ FixtureDetailPage(
|
||||
fixture *db.Fixture,
|
||||
@@ -24,11 +14,22 @@ templ FixtureDetailPage(
|
||||
history []*db.FixtureSchedule,
|
||||
canSchedule bool,
|
||||
userTeamID int,
|
||||
result *db.FixtureResult,
|
||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||
activeTab string,
|
||||
) {
|
||||
{{
|
||||
permCache := contexts.Permissions(ctx)
|
||||
canManage := permCache.HasPermission(permissions.FixturesManage)
|
||||
backURL := fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", fixture.Season.ShortName, fixture.League.ShortName)
|
||||
isFinalized := result != nil && result.Finalized
|
||||
if activeTab == "" {
|
||||
activeTab = "overview"
|
||||
}
|
||||
// Force overview if schedule tab is hidden (result finalized)
|
||||
if isFinalized && activeTab == "schedule" {
|
||||
activeTab = "overview"
|
||||
}
|
||||
}}
|
||||
@baseview.Layout(fmt.Sprintf("%s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
|
||||
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
||||
@@ -67,15 +68,434 @@ templ FixtureDetailPage(
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab Navigation (hidden when only one tab) -->
|
||||
if !isFinalized {
|
||||
<nav class="bg-surface0 border-b border-surface1">
|
||||
<ul class="flex flex-wrap">
|
||||
@fixtureTabItem("overview", "Overview", activeTab, fixture)
|
||||
@fixtureTabItem("schedule", "Schedule", activeTab, fixture)
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
</div>
|
||||
<!-- Schedule Status + Actions -->
|
||||
<!-- Tab Content -->
|
||||
if activeTab == "overview" {
|
||||
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage)
|
||||
} else if activeTab == "schedule" {
|
||||
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ fixtureTabItem(section string, label string, activeTab string, fixture *db.Fixture) {
|
||||
{{
|
||||
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("/fixtures/%d", fixture.ID)
|
||||
if section != "overview" {
|
||||
url = fmt.Sprintf("/fixtures/%d?tab=%s", fixture.ID, section)
|
||||
}
|
||||
}}
|
||||
<li class="inline-block">
|
||||
<a
|
||||
href={ templ.SafeURL(url) }
|
||||
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||||
>
|
||||
{ label }
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
|
||||
// ==================== Overview Tab ====================
|
||||
templ fixtureOverviewTab(
|
||||
fixture *db.Fixture,
|
||||
currentSchedule *db.FixtureSchedule,
|
||||
result *db.FixtureResult,
|
||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||
canManage bool,
|
||||
) {
|
||||
<div class="space-y-6">
|
||||
<!-- Result + Schedule Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
if result != nil {
|
||||
@fixtureResultDisplay(fixture, result)
|
||||
} else if canManage {
|
||||
@fixtureUploadPrompt(fixture)
|
||||
} else {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||||
<p class="text-subtext1 text-sm">No result has been uploaded for this fixture yet.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
@fixtureScheduleSummary(fixture, currentSchedule, result)
|
||||
</div>
|
||||
</div>
|
||||
<!-- Team Rosters -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result)
|
||||
@fixtureTeamSection(fixture.AwayTeam, rosters["away"], "away", result)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ fixtureScheduleSummary(fixture *db.Fixture, schedule *db.FixtureSchedule, result *db.FixtureResult) {
|
||||
{{
|
||||
isPlayed := result != nil && result.Finalized
|
||||
}}
|
||||
<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 isPlayed {
|
||||
<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">
|
||||
Forfeit
|
||||
</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 fixtureResultDisplay(fixture *db.Fixture, result *db.FixtureResult) {
|
||||
{{
|
||||
isOT := strings.EqualFold(result.EndReason, "Overtime")
|
||||
homeWon := result.Winner == "home"
|
||||
awayWon := result.Winner == "away"
|
||||
}}
|
||||
<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">Match Result</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
if result.Finalized {
|
||||
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
||||
Finalized
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
Pending Review
|
||||
</span>
|
||||
}
|
||||
if !result.Finalized && result.TamperingDetected {
|
||||
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
|
||||
Inconsistent Data
|
||||
</span>
|
||||
}
|
||||
if result.Finalized && result.TamperingDetected && result.TamperingReason != nil {
|
||||
<span class="relative group">
|
||||
<span class="text-yellow text-lg cursor-help">⚠</span>
|
||||
<span
|
||||
class="absolute right-0 top-full mt-1 w-72 bg-crust border border-surface1 rounded-lg
|
||||
p-3 text-xs text-subtext0 shadow-lg opacity-0 invisible
|
||||
group-hover:opacity-100 group-hover:visible transition-all z-50"
|
||||
>
|
||||
<span class="text-yellow font-semibold block mb-1">Inconsistent Data</span>
|
||||
{ *result.TamperingReason }
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
if !result.Finalized && result.TamperingDetected && result.TamperingReason != nil {
|
||||
<div class="bg-red/10 border border-red/30 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-red font-medium text-sm">Warning: Inconsistent Data</span>
|
||||
</div>
|
||||
<p class="text-red/80 text-xs">{ *result.TamperingReason }</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Score Display -->
|
||||
<div class="flex items-center justify-center gap-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
if homeWon {
|
||||
<span class="text-2xl">🏆</span>
|
||||
}
|
||||
if fixture.HomeTeam.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",
|
||||
fixture.HomeTeam.Color, fixture.HomeTeam.Color, fixture.HomeTeam.Color) }
|
||||
>
|
||||
{ fixture.HomeTeam.ShortName }
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||||
{ fixture.HomeTeam.ShortName }
|
||||
</span>
|
||||
}
|
||||
<span class="text-4xl font-bold text-text">{ fmt.Sprint(result.HomeScore) }</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-4xl text-subtext0 font-light leading-none">–</span>
|
||||
if isOT {
|
||||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-semibold mt-1">
|
||||
OT
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-4xl font-bold text-text">{ fmt.Sprint(result.AwayScore) }</span>
|
||||
if fixture.AwayTeam.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",
|
||||
fixture.AwayTeam.Color, fixture.AwayTeam.Color, fixture.AwayTeam.Color) }
|
||||
>
|
||||
{ fixture.AwayTeam.ShortName }
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||||
{ fixture.AwayTeam.ShortName }
|
||||
</span>
|
||||
}
|
||||
if awayWon {
|
||||
<span class="text-2xl">🏆</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ fixtureUploadPrompt(fixture *db.Fixture) {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||||
<div class="text-4xl mb-3">📋</div>
|
||||
<p class="text-lg text-text font-medium mb-2">No Result Uploaded</p>
|
||||
<p class="text-sm text-subtext1 mb-4">Upload match log files to record the result of this fixture.</p>
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/results/upload", fixture.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 fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side string, result *db.FixtureResult) {
|
||||
{{
|
||||
// Separate playing and bench players
|
||||
var playing []*db.PlayerWithPlayStatus
|
||||
var bench []*db.PlayerWithPlayStatus
|
||||
for _, p := range players {
|
||||
if result == nil || p.Played {
|
||||
playing = append(playing, p)
|
||||
} else {
|
||||
bench = append(bench, p)
|
||||
}
|
||||
}
|
||||
showStats := result != nil && result.Finalized
|
||||
if showStats {
|
||||
// Sort playing players by score descending
|
||||
sort.Slice(playing, func(i, j int) bool {
|
||||
si, sj := 0, 0
|
||||
if playing[i].Stats != nil && playing[i].Stats.Score != nil {
|
||||
si = *playing[i].Stats.Score
|
||||
}
|
||||
if playing[j].Stats != nil && playing[j].Stats.Score != nil {
|
||||
sj = *playing[j].Stats.Score
|
||||
}
|
||||
return si > sj
|
||||
})
|
||||
} else {
|
||||
// Sort with managers first
|
||||
sort.SliceStable(playing, func(i, j int) bool {
|
||||
return playing[i].IsManager && !playing[j].IsManager
|
||||
})
|
||||
sort.SliceStable(bench, func(i, j int) bool {
|
||||
return bench[i].IsManager && !bench[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 text-text">
|
||||
{ team.Name }
|
||||
</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 if showStats {
|
||||
<!-- Stats table view for finalized results -->
|
||||
if len(playing) > 0 {
|
||||
<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="Score">SC</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
for _, p := range playing {
|
||||
<tr class="hover:bg-surface0 transition-colors">
|
||||
<td class="px-3 py-2 text-sm text-text">
|
||||
<span class="flex items-center gap-1.5">
|
||||
{ p.Player.DisplayName() }
|
||||
if p.IsManager {
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
★
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
if p.Stats != nil {
|
||||
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ intPtrStr(p.Stats.Score) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Goals) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Assists) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Saves) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Shots) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Blocks) }</td>
|
||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Passes) }</td>
|
||||
} else {
|
||||
<td colspan="7" class="px-2 py-2 text-center text-xs text-subtext1">—</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
if len(bench) > 0 {
|
||||
<div class="border-t border-surface1 px-4 py-3">
|
||||
<p class="text-xs text-subtext0 font-semibold uppercase mb-2">Bench</p>
|
||||
<div class="space-y-1">
|
||||
for _, p := range bench {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded">
|
||||
<span class="text-sm text-subtext1">
|
||||
{ p.Player.DisplayName() }
|
||||
</span>
|
||||
if p.IsManager {
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
★ Manager
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
<!-- Simple list view (no result or pending review) -->
|
||||
<div class="p-4">
|
||||
if len(playing) > 0 {
|
||||
if result != nil {
|
||||
<p class="text-xs text-subtext0 font-semibold uppercase mb-2">Playing</p>
|
||||
}
|
||||
<div class="space-y-1">
|
||||
for _, p := range playing {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface0 transition">
|
||||
<span class="text-sm text-text">
|
||||
{ p.Player.DisplayName() }
|
||||
</span>
|
||||
if p.IsManager {
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
★ Manager
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if result != nil && len(bench) > 0 {
|
||||
<p class="text-xs text-subtext0 font-semibold uppercase mt-4 mb-2">Bench</p>
|
||||
<div class="space-y-1">
|
||||
for _, p := range bench {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded">
|
||||
<span class="text-sm text-subtext1">
|
||||
{ p.Player.DisplayName() }
|
||||
</span>
|
||||
if p.IsManager {
|
||||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||||
★ Manager
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// ==================== Schedule Tab ====================
|
||||
templ fixtureScheduleTab(
|
||||
fixture *db.Fixture,
|
||||
currentSchedule *db.FixtureSchedule,
|
||||
history []*db.FixtureSchedule,
|
||||
canSchedule bool,
|
||||
canManage bool,
|
||||
userTeamID int,
|
||||
) {
|
||||
<div class="space-y-6">
|
||||
@fixtureScheduleStatus(fixture, currentSchedule, canSchedule, canManage, userTeamID)
|
||||
@fixtureScheduleActions(fixture, currentSchedule, canSchedule, canManage, userTeamID)
|
||||
@fixtureScheduleHistory(fixture, history)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ fixtureScheduleStatus(
|
||||
@@ -108,7 +528,8 @@ templ fixtureScheduleStatus(
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl mb-3">⏳</div>
|
||||
<p class="text-lg text-text font-medium">
|
||||
Proposed: { formatScheduleTime(current.ScheduledTime) }
|
||||
Proposed:
|
||||
@localtime(current.ScheduledTime, "datetime")
|
||||
</p>
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
Proposed by
|
||||
@@ -161,7 +582,8 @@ templ fixtureScheduleStatus(
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl mb-3">✅</div>
|
||||
<p class="text-lg text-green font-medium">
|
||||
Confirmed: { formatScheduleTime(current.ScheduledTime) }
|
||||
Confirmed:
|
||||
@localtime(current.ScheduledTime, "datetime")
|
||||
</p>
|
||||
<p class="text-sm text-subtext1 mt-1">
|
||||
Both teams have agreed on this time.
|
||||
@@ -278,7 +700,19 @@ templ fixtureScheduleActions(
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-subtext0 mb-1">Date & Time</label>
|
||||
<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"
|
||||
@@ -311,7 +745,19 @@ templ fixtureScheduleActions(
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-subtext0 mb-1">New Date & Time</label>
|
||||
<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"
|
||||
@@ -507,7 +953,7 @@ templ scheduleHistoryItem(schedule *db.FixtureSchedule, isCurrent bool) {
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-subtext1">
|
||||
{ formatHistoryTime(schedule.CreatedAt) }
|
||||
@localtimeUnix(schedule.CreatedAt, "histdate")
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
@@ -518,7 +964,9 @@ templ scheduleHistoryItem(schedule *db.FixtureSchedule, isCurrent bool) {
|
||||
if schedule.ScheduledTime != nil {
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-subtext0">Time:</span>
|
||||
<span class="text-text">{ formatScheduleTime(schedule.ScheduledTime) }</span>
|
||||
<span class="text-text">
|
||||
@localtime(schedule.ScheduledTime, "datetime")
|
||||
</span>
|
||||
</div>
|
||||
} else {
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
|
||||
234
internal/view/seasonsview/fixture_review_result.templ
Normal file
234
internal/view/seasonsview/fixture_review_result.templ
Normal file
@@ -0,0 +1,234 @@
|
||||
package seasonsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||
import "fmt"
|
||||
|
||||
templ FixtureReviewResultPage(
|
||||
fixture *db.Fixture,
|
||||
result *db.FixtureResult,
|
||||
unmappedPlayers []string,
|
||||
) {
|
||||
{{
|
||||
backURL := fmt.Sprintf("/fixtures/%d", fixture.ID)
|
||||
}}
|
||||
@baseview.Layout(fmt.Sprintf("Review Result — %s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
|
||||
<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 Match Result</h1>
|
||||
<p class="text-sm text-subtext1">
|
||||
{ fixture.HomeTeam.Name } vs { fixture.AwayTeam.Name }
|
||||
<span class="text-subtext0 ml-1">
|
||||
Round { fmt.Sprint(fixture.Round) }
|
||||
</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 Fixture
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Warnings Section -->
|
||||
if result.TamperingDetected || len(unmappedPlayers) > 0 {
|
||||
<div class="space-y-4 mb-6">
|
||||
if result.TamperingDetected && result.TamperingReason != nil {
|
||||
<div class="bg-red/10 border border-red/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<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-2">
|
||||
This does not block finalization but should be reviewed carefully.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
if len(unmappedPlayers) > 0 {
|
||||
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-yellow font-bold text-sm">⚠ Unmapped Players</span>
|
||||
</div>
|
||||
<p class="text-yellow/80 text-sm mb-2">
|
||||
The following players could not be matched to registered players.
|
||||
They may be free agents or have unregistered Slapshot IDs.
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
|
||||
for _, p := range unmappedPlayers {
|
||||
<li>{ p }</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Score Overview -->
|
||||
<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">Score</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-center gap-8 py-4">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-subtext0 mb-1">{ fixture.HomeTeam.Name }</p>
|
||||
<p class="text-4xl font-bold text-text">{ 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">{ fixture.AwayTeam.Name }</p>
|
||||
<p class="text-4xl font-bold text-text">{ 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:
|
||||
if result.Winner == "home" {
|
||||
{ fixture.HomeTeam.Name }
|
||||
} else if result.Winner == "away" {
|
||||
{ fixture.AwayTeam.Name }
|
||||
} else {
|
||||
Draw
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Player Stats Tables -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
@reviewTeamStats(fixture.HomeTeam, result, "home")
|
||||
@reviewTeamStats(fixture.AwayTeam, result, "away")
|
||||
</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">
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/fixtures/%d/results/finalize", fixture.ID) }
|
||||
hx-swap="none"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-6 py-3 bg-green hover:bg-green/75 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer text-lg"
|
||||
>
|
||||
Finalize Result
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
@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
|
||||
font-medium transition hover:cursor-pointer text-lg"
|
||||
>
|
||||
Discard & Re-upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
||||
{{
|
||||
// Collect unique players for this team across all periods
|
||||
// We'll show the period 3 (final/cumulative) stats
|
||||
type playerStat struct {
|
||||
Username string
|
||||
PlayerID *int
|
||||
Stats *db.FixtureResultPlayerStats
|
||||
}
|
||||
finalStats := []*playerStat{}
|
||||
seen := map[string]bool{}
|
||||
// Find period 3 stats for this team (cumulative)
|
||||
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 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 text-text">
|
||||
if side == "home" {
|
||||
Home —
|
||||
} else {
|
||||
Away —
|
||||
}
|
||||
{ team.Name }
|
||||
</h3>
|
||||
</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="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 text-text">
|
||||
{ ps.Username }
|
||||
if ps.PlayerID == nil {
|
||||
<span class="text-yellow text-xs ml-1" title="Unmapped player">?</span>
|
||||
}
|
||||
</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="8" class="px-3 py-4 text-center text-sm text-subtext1">
|
||||
No player stats recorded
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
func intPtrStr(v *int) string {
|
||||
if v == nil {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprint(*v)
|
||||
}
|
||||
118
internal/view/seasonsview/fixture_upload_result.templ
Normal file
118
internal/view/seasonsview/fixture_upload_result.templ
Normal file
@@ -0,0 +1,118 @@
|
||||
package seasonsview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||
import "fmt"
|
||||
|
||||
templ FixtureUploadResultPage(fixture *db.Fixture) {
|
||||
{{
|
||||
backURL := fmt.Sprintf("/fixtures/%d", fixture.ID)
|
||||
}}
|
||||
@baseview.Layout(fmt.Sprintf("Upload Result — %s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
|
||||
<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 Match Logs</h1>
|
||||
<p class="text-sm text-subtext1">
|
||||
{ fixture.HomeTeam.Name } vs { fixture.AwayTeam.Name }
|
||||
<span class="text-subtext0 ml-1">
|
||||
Round { fmt.Sprint(fixture.Round) }
|
||||
</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">
|
||||
<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. Each file corresponds to one period of the match.
|
||||
The files will be validated for consistency.
|
||||
</p>
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/fixtures/%d/results/upload", fixture.ID) }
|
||||
hx-swap="none"
|
||||
hx-encoding="multipart/form-data"
|
||||
class="space-y-6"
|
||||
>
|
||||
<!-- Period 1 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text mb-2">
|
||||
Period 1
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="period_1"
|
||||
accept=".json"
|
||||
required
|
||||
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>
|
||||
<!-- Period 2 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text mb-2">
|
||||
Period 2
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="period_2"
|
||||
accept=".json"
|
||||
required
|
||||
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>
|
||||
<!-- Period 3 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text mb-2">
|
||||
Period 3
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="period_3"
|
||||
accept=".json"
|
||||
required
|
||||
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>
|
||||
<!-- 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
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
43
internal/view/seasonsview/localtime.templ
Normal file
43
internal/view/seasonsview/localtime.templ
Normal file
@@ -0,0 +1,43 @@
|
||||
package seasonsview
|
||||
|
||||
import "time"
|
||||
|
||||
// formatISO returns an ISO 8601 UTC string for use in <time datetime="...">.
|
||||
func formatISO(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// formatISOUnix returns an ISO 8601 UTC string from a Unix timestamp.
|
||||
func formatISOUnix(unix int64) string {
|
||||
return time.Unix(unix, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// localtime renders a <time> element that will be formatted client-side
|
||||
// in the user's local timezone. The format parameter maps to data-localtime:
|
||||
// "date" → "Mon 2 Jan 2026"
|
||||
// "time" → "3:04 PM"
|
||||
// "datetime" → "Mon 2 Jan 2026 at 3:04 PM"
|
||||
// "short" → "Mon 2 Jan 3:04 PM"
|
||||
// "histdate" → "2 Jan 2006 15:04"
|
||||
templ localtime(t *time.Time, format string) {
|
||||
if t != nil {
|
||||
<time datetime={ formatISO(t) } data-localtime={ format }>
|
||||
{ t.UTC().Format("2 Jan 2006 15:04 UTC") }
|
||||
</time>
|
||||
} else {
|
||||
No time set
|
||||
}
|
||||
}
|
||||
|
||||
// localtimeUnix renders a <time> element from a Unix timestamp.
|
||||
templ localtimeUnix(unix int64, format string) {
|
||||
{{
|
||||
t := time.Unix(unix, 0)
|
||||
}}
|
||||
<time datetime={ formatISOUnix(unix) } data-localtime={ format }>
|
||||
{ t.UTC().Format("2 Jan 2006 15:04 UTC") }
|
||||
</time>
|
||||
}
|
||||
@@ -4,14 +4,16 @@ 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 "fmt"
|
||||
import "sort"
|
||||
import "time"
|
||||
|
||||
templ SeasonLeagueFixturesPage(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
|
||||
templ SeasonLeagueFixturesPage(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) {
|
||||
@SeasonLeagueLayout("fixtures", season, league) {
|
||||
@SeasonLeagueFixtures(season, league, fixtures, scheduleMap)
|
||||
@SeasonLeagueFixtures(season, league, fixtures, scheduleMap, resultMap)
|
||||
}
|
||||
}
|
||||
|
||||
templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
|
||||
templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) {
|
||||
{{
|
||||
permCache := contexts.Permissions(ctx)
|
||||
canManage := permCache.HasPermission(permissions.FixturesManage)
|
||||
@@ -35,6 +37,23 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
|
||||
}
|
||||
groups[idx].Fixtures = append(groups[idx].Fixtures, f)
|
||||
}
|
||||
|
||||
// Sort fixtures within each group by scheduled time
|
||||
// Scheduled fixtures first (by time), then TBD last
|
||||
farFuture := time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i := range groups {
|
||||
sort.Slice(groups[i].Fixtures, func(a, b int) bool {
|
||||
ta := farFuture
|
||||
tb := farFuture
|
||||
if sa, ok := scheduleMap[groups[i].Fixtures[a].ID]; ok && sa.ScheduledTime != nil {
|
||||
ta = *sa.ScheduledTime
|
||||
}
|
||||
if sb, ok := scheduleMap[groups[i].Fixtures[b].ID]; ok && sb.ScheduledTime != nil {
|
||||
tb = *sb.ScheduledTime
|
||||
}
|
||||
return ta.Before(tb)
|
||||
})
|
||||
}
|
||||
}}
|
||||
<div>
|
||||
if canManage {
|
||||
@@ -55,20 +74,78 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
|
||||
} else {
|
||||
<div class="space-y-4">
|
||||
for _, group := range groups {
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-mantle border-b border-surface1 px-4 py-3">
|
||||
{{
|
||||
playedCount := 0
|
||||
for _, f := range group.Fixtures {
|
||||
if res, ok := resultMap[f.ID]; ok && res.Finalized {
|
||||
playedCount++
|
||||
}
|
||||
}
|
||||
hasPlayed := playedCount > 0
|
||||
allPlayed := playedCount == len(group.Fixtures)
|
||||
}}
|
||||
<div
|
||||
class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"
|
||||
x-data="{ showPlayed: false }"
|
||||
>
|
||||
<div class="bg-mantle border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold text-text">Game Week { fmt.Sprint(group.Week) }</h3>
|
||||
if hasPlayed {
|
||||
<button
|
||||
type="button"
|
||||
@click="showPlayed = !showPlayed"
|
||||
class="text-xs px-2.5 py-1 rounded-lg transition cursor-pointer
|
||||
bg-surface1 hover:bg-surface2 text-subtext0 hover:text-text"
|
||||
>
|
||||
<span x-show="!showPlayed">Show played</span>
|
||||
<span x-show="showPlayed" x-cloak>Hide played</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="divide-y divide-surface1">
|
||||
for _, fixture := range group.Fixtures {
|
||||
{{
|
||||
sched, hasSchedule := scheduleMap[fixture.ID]
|
||||
_ = sched
|
||||
res, hasResult := resultMap[fixture.ID]
|
||||
_ = res
|
||||
isPlayed := hasResult && res.Finalized
|
||||
}}
|
||||
if isPlayed {
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
||||
x-show="showPlayed"
|
||||
x-cloak
|
||||
class="px-4 py-3 flex items-center justify-between hover:bg-surface1 transition hover:cursor-pointer block"
|
||||
>
|
||||
@fixtureListItem(fixture, sched, hasSchedule, res, hasResult)
|
||||
</a>
|
||||
} else {
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
||||
class="px-4 py-3 flex items-center justify-between hover:bg-surface1 transition hover:cursor-pointer block"
|
||||
>
|
||||
@fixtureListItem(fixture, sched, hasSchedule, res, hasResult)
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
if allPlayed {
|
||||
<div
|
||||
x-show="!showPlayed"
|
||||
class="px-4 py-3 text-center text-xs text-subtext1 italic"
|
||||
>
|
||||
All fixtures played
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ fixtureListItem(fixture *db.Fixture, sched *db.FixtureSchedule, hasSchedule bool, res *db.FixtureResult, hasResult bool) {
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded">
|
||||
R{ fmt.Sprint(fixture.Round) }
|
||||
@@ -81,21 +158,29 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
|
||||
{ fixture.AwayTeam.Name }
|
||||
</span>
|
||||
</div>
|
||||
if hasSchedule && sched.ScheduledTime != nil {
|
||||
if hasResult {
|
||||
<span class="flex items-center gap-2">
|
||||
if res.Winner == "home" {
|
||||
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
||||
<span class="text-xs text-subtext0">–</span>
|
||||
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
|
||||
} else if res.Winner == "away" {
|
||||
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
|
||||
<span class="text-xs text-subtext0">–</span>
|
||||
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
||||
} else {
|
||||
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
||||
<span class="text-xs text-subtext0">–</span>
|
||||
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
||||
}
|
||||
</span>
|
||||
} else if hasSchedule && sched.ScheduledTime != nil {
|
||||
<span class="text-xs text-green font-medium">
|
||||
{ sched.ScheduledTime.Format("Mon 2 Jan 3:04 PM") }
|
||||
@localtime(sched.ScheduledTime, "short")
|
||||
</span>
|
||||
} else {
|
||||
<span class="text-xs text-subtext1">
|
||||
TBD
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -458,7 +458,7 @@ templ teamFixtureRow(team *db.Team, fixture *db.Fixture, scheduleMap map[int]*db
|
||||
</div>
|
||||
if hasSchedule && sched.ScheduledTime != nil {
|
||||
<span class="text-xs text-green font-medium shrink-0">
|
||||
{ sched.ScheduledTime.Format("Mon 2 Jan 3:04 PM") }
|
||||
@localtime(sched.ScheduledTime, "short")
|
||||
</span>
|
||||
} else {
|
||||
<span class="text-xs text-subtext1 shrink-0">
|
||||
|
||||
180
pkg/slapshotapi/tampering.go
Normal file
180
pkg/slapshotapi/tampering.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package slapshotapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// DetectTampering analyzes 3 period logs for modification signs.
|
||||
// Returns (isTampering bool, reason string, error).
|
||||
func DetectTampering(logs []*MatchLog) (bool, string, error) {
|
||||
if len(logs) != 3 {
|
||||
return false, "", errors.New("exactly 3 period logs are required")
|
||||
}
|
||||
|
||||
reasons := []string{}
|
||||
|
||||
// Check metadata consistency
|
||||
tampered, reason, err := ValidateMetadataConsistency(logs)
|
||||
if err != nil {
|
||||
return false, "", errors.Wrap(err, "ValidateMetadataConsistency")
|
||||
}
|
||||
if tampered {
|
||||
reasons = append(reasons, reason)
|
||||
}
|
||||
|
||||
// Check score progression
|
||||
tampered, reason, err = ValidateScoreProgression(logs)
|
||||
if err != nil {
|
||||
return false, "", errors.Wrap(err, "ValidateScoreProgression")
|
||||
}
|
||||
if tampered {
|
||||
reasons = append(reasons, reason)
|
||||
}
|
||||
|
||||
// Check player consistency
|
||||
tampered, reason, err = ValidatePlayerConsistency(logs)
|
||||
if err != nil {
|
||||
return false, "", errors.Wrap(err, "ValidatePlayerConsistency")
|
||||
}
|
||||
if tampered {
|
||||
reasons = append(reasons, reason)
|
||||
}
|
||||
|
||||
if len(reasons) > 0 {
|
||||
return true, strings.Join(reasons, "; "), nil
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// ValidateMetadataConsistency checks that arena, match_length, and
|
||||
// custom_mercy_rule are consistent across all periods, and warns if any
|
||||
// period has periods_enabled set to "False" (periods disabled).
|
||||
func ValidateMetadataConsistency(logs []*MatchLog) (bool, string, error) {
|
||||
if len(logs) != 3 {
|
||||
return false, "", errors.New("exactly 3 period logs are required")
|
||||
}
|
||||
|
||||
ref := logs[0]
|
||||
inconsistencies := []string{}
|
||||
|
||||
for i := 1; i < len(logs); i++ {
|
||||
log := logs[i]
|
||||
if log.Arena != ref.Arena {
|
||||
inconsistencies = append(inconsistencies,
|
||||
fmt.Sprintf("arena differs in period %d (%q vs %q)", i+1, log.Arena, ref.Arena))
|
||||
}
|
||||
if log.MatchLength != ref.MatchLength {
|
||||
inconsistencies = append(inconsistencies,
|
||||
fmt.Sprintf("match_length differs in period %d (%s vs %s)", i+1, log.MatchLength, ref.MatchLength))
|
||||
}
|
||||
if log.CustomMercyRule != ref.CustomMercyRule {
|
||||
inconsistencies = append(inconsistencies,
|
||||
fmt.Sprintf("custom_mercy_rule differs in period %d (%s vs %s)", i+1, log.CustomMercyRule, ref.CustomMercyRule))
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if any period has periods disabled
|
||||
for i, log := range logs {
|
||||
if strings.EqualFold(log.PeriodsEnabled, "False") {
|
||||
inconsistencies = append(inconsistencies,
|
||||
fmt.Sprintf("periods_enabled is False in period %d", i+1))
|
||||
}
|
||||
}
|
||||
|
||||
if len(inconsistencies) > 0 {
|
||||
return true, "metadata inconsistency: " + strings.Join(inconsistencies, ", "), nil
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// ValidateScoreProgression checks that scores only increase or stay the same
|
||||
// across periods (cumulative stats).
|
||||
func ValidateScoreProgression(logs []*MatchLog) (bool, string, error) {
|
||||
if len(logs) != 3 {
|
||||
return false, "", errors.New("exactly 3 period logs are required")
|
||||
}
|
||||
|
||||
issues := []string{}
|
||||
|
||||
for i := 1; i < len(logs); i++ {
|
||||
prev := logs[i-1]
|
||||
curr := logs[i]
|
||||
if curr.Score.Home < prev.Score.Home {
|
||||
issues = append(issues,
|
||||
fmt.Sprintf("home score decreased from period %d (%d) to period %d (%d)",
|
||||
i, prev.Score.Home, i+1, curr.Score.Home))
|
||||
}
|
||||
if curr.Score.Away < prev.Score.Away {
|
||||
issues = append(issues,
|
||||
fmt.Sprintf("away score decreased from period %d (%d) to period %d (%d)",
|
||||
i, prev.Score.Away, i+1, curr.Score.Away))
|
||||
}
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
return true, "score regression: " + strings.Join(issues, ", "), nil
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// ValidatePlayerConsistency checks that player rosters remain relatively stable
|
||||
// across periods. A player present in period 1 should ideally still be present
|
||||
// in later periods. Drastic roster changes are suspicious.
|
||||
func ValidatePlayerConsistency(logs []*MatchLog) (bool, string, error) {
|
||||
if len(logs) != 3 {
|
||||
return false, "", errors.New("exactly 3 period logs are required")
|
||||
}
|
||||
|
||||
// Build player sets per period
|
||||
periodPlayers := make([]map[string]string, 3) // game_user_id -> team
|
||||
for i, log := range logs {
|
||||
periodPlayers[i] = make(map[string]string)
|
||||
for _, p := range log.Players {
|
||||
periodPlayers[i][p.GameUserID] = p.Team
|
||||
}
|
||||
}
|
||||
|
||||
issues := []string{}
|
||||
|
||||
// Check for team-switching between periods (same player, different team)
|
||||
for i := 1; i < len(logs); i++ {
|
||||
for id, prevTeam := range periodPlayers[i-1] {
|
||||
if currTeam, exists := periodPlayers[i][id]; exists {
|
||||
if currTeam != prevTeam {
|
||||
issues = append(issues,
|
||||
fmt.Sprintf("player %s switched teams between period %d and %d",
|
||||
id, i, i+1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for drastic roster changes (more than 50% different players)
|
||||
for i := 1; i < len(logs); i++ {
|
||||
prev := periodPlayers[i-1]
|
||||
curr := periodPlayers[i]
|
||||
if len(prev) == 0 {
|
||||
continue
|
||||
}
|
||||
missing := 0
|
||||
for id := range prev {
|
||||
if _, exists := curr[id]; !exists {
|
||||
missing++
|
||||
}
|
||||
}
|
||||
ratio := float64(missing) / float64(len(prev))
|
||||
if ratio > 0.5 {
|
||||
issues = append(issues,
|
||||
fmt.Sprintf("more than 50%% of players changed between period %d and %d (%d/%d missing)",
|
||||
i, i+1, missing, len(prev)))
|
||||
}
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
return true, "player inconsistency: " + strings.Join(issues, ", "), nil
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
Reference in New Issue
Block a user